fix(prompt-gate): block prompts into pending tool turns
This commit is contained in:
+46
@@ -325,6 +325,52 @@ Expected: only interrupted `running` / `pending` call IDs are recovered, complet
|
|||||||
- `bun run typecheck`: pass.
|
- `bun run typecheck`: pass.
|
||||||
- `bun run build`: pass.
|
- `bun run build`: pass.
|
||||||
|
|
||||||
|
## Review-work Follow-up
|
||||||
|
|
||||||
|
- Review-work code-quality/context-mining agents found three valid P1 gaps:
|
||||||
|
- Synthetic `session.status { type: "idle" }` normalized idles skipped the interrupted-tool recovery preflight.
|
||||||
|
- `finish: "tool-calls"` was incorrectly treated as a finished assistant message by idle recovery.
|
||||||
|
- Idle recovery added a top-level `session.messages` call without a timeout.
|
||||||
|
- Context mining also flagged a broader route: `promptAsyncAfterSessionIdle` could still dispatch into an idle session whose latest assistant turn had `pending` / `running` tool state, e.g. team live delivery outside an idle event.
|
||||||
|
- Red tests added:
|
||||||
|
- `plugin/event.test.ts`: synthetic `session.status` idle recovers and skips later idle hooks.
|
||||||
|
- `session-recovery/hook.test.ts`: `finish: "tool-calls"` plus pending/running tools is recovered.
|
||||||
|
- `session-recovery/hook.test.ts`: hanging `session.messages` during idle recovery times out and returns `false`.
|
||||||
|
- `prompt-async-gate.test.ts`: generic internal `promptAsync` skips when the latest assistant is waiting on tools.
|
||||||
|
- `prompt-async-gate.test.ts`: tool-state check can be disabled for deliberate tool-result recovery.
|
||||||
|
- `prompt-async-gate.test.ts`: generic latest-message fetch timeout does not create a new hang.
|
||||||
|
- Fixes:
|
||||||
|
- `src/plugin/event.ts` now applies the same interrupted-tool recovery gate before both real and synthetic idle fanout.
|
||||||
|
- `src/hooks/session-recovery/hook.ts` treats `finish: "tool-calls"` as waiting, not finished.
|
||||||
|
- `src/hooks/session-recovery/interrupted-idle-message-fetch-timeout.ts` bounds idle recovery message fetches at 5s.
|
||||||
|
- `src/shared/prompt-async-gate.ts` skips generic internal prompts when latest assistant is still waiting on tools, with a timeout-bound `session.messages` check.
|
||||||
|
- `recoverToolResultMissing` passes `checkToolState: false`, because recovery intentionally sends `tool_result` parts into a waiting tool turn.
|
||||||
|
- Post-fix validation:
|
||||||
|
- `bun test src/hooks/shared/prompt-async-gate.test.ts src/hooks/session-recovery/recover-tool-result-missing.test.ts src/hooks/session-recovery/hook.test.ts src/features/background-agent/task-completion-cleanup.test.ts src/plugin/event.test.ts --bail`: 72 pass, 0 fail.
|
||||||
|
- `bun --install=fallback /Users/yeongyu/.config/opencode/skills/typescript-programmer/scripts/check-no-excuse-rules.ts src/shared/prompt-async-gate.ts src/hooks/shared/prompt-async-gate.test.ts src/hooks/session-recovery/hook.ts src/hooks/session-recovery/hook.test.ts src/hooks/session-recovery/recover-tool-result-missing.ts src/hooks/session-recovery/recover-tool-result-missing.test.ts src/hooks/session-recovery/interrupted-idle-message-fetch-timeout.ts src/plugin/event.ts src/plugin/event.test.ts`: pass.
|
||||||
|
- `bun run typecheck`: pass.
|
||||||
|
- `bun run build`: pass.
|
||||||
|
- Full-suite follow-up caught a prompt-gate ordering regression in the no-space validation worktree:
|
||||||
|
- Failing tests: `BackgroundManager tmux callback ordering > starts promptAsync before a blocking tmux callback resolves` and `background-agent spawner tmux callback ordering > fires promptAsync before tmux callback resolves`.
|
||||||
|
- Cause: even with no `session.messages` API present, the async helper was still awaited, yielding before prompt dispatch.
|
||||||
|
- Fix: guard the latest-assistant tool-state check before awaiting it; when `client.session.messages` is unavailable, the old synchronous dispatch ordering is preserved.
|
||||||
|
- Targeted ordering validation:
|
||||||
|
- `bun test src/features/background-agent/manager.test.ts --test-name-pattern "starts promptAsync before"`: pass.
|
||||||
|
- `bun test src/features/background-agent/spawner.test.ts --test-name-pattern "fires promptAsync before"`: pass.
|
||||||
|
- `bun test src/hooks/shared/prompt-async-gate.test.ts --test-name-pattern "waiting on tools|tool-state check|latest-message fetch"`: pass.
|
||||||
|
- Final focused validation:
|
||||||
|
- `bun test src/hooks/shared/prompt-async-gate.test.ts src/hooks/session-recovery/recover-tool-result-missing.test.ts src/hooks/session-recovery/hook.test.ts src/features/background-agent/task-completion-cleanup.test.ts src/plugin/event.test.ts src/features/background-agent/manager.test.ts src/features/background-agent/spawner.test.ts --bail`
|
||||||
|
- Result: 259 pass, 0 fail.
|
||||||
|
- Final no-excuse/type/build validation:
|
||||||
|
- `bun --install=fallback /Users/yeongyu/.config/opencode/skills/typescript-programmer/scripts/check-no-excuse-rules.ts <9 changed TS paths>`: pass.
|
||||||
|
- `bun run typecheck`: pass.
|
||||||
|
- `bun run build`: pass.
|
||||||
|
- Final no-space validation worktree:
|
||||||
|
- Base: `origin/dev` at `4d417a33b6951d3194802dcf102e6094af79e799`.
|
||||||
|
- Worktree: `/tmp/omo-ci-validation.OhVAMY`.
|
||||||
|
- Command: `bun test`.
|
||||||
|
- Result: 7034 pass, 1 skip, 0 fail across 725 files.
|
||||||
|
|
||||||
## Final Status Before PR
|
## Final Status Before PR
|
||||||
|
|
||||||
- Product behavior change: only malformed idle events with unfinished latest assistant messages and `pending` / `running` tool parts get synthetic interrupted tool results.
|
- Product behavior change: only malformed idle events with unfinished latest assistant messages and `pending` / `running` tool parts get synthetic interrupted tool results.
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { afterEach, describe, expect, test } from "bun:test"
|
import { afterEach, describe, expect, test } from "bun:test"
|
||||||
import { createSessionRecoveryHook } from "./hook"
|
import { createSessionRecoveryHook } from "./hook"
|
||||||
|
import { _setInterruptedIdleMessagesFetchTimeoutMsForTesting } from "./interrupted-idle-message-fetch-timeout"
|
||||||
import { releaseAllPromptAsyncReservationsForTesting } from "../../shared/prompt-async-gate"
|
import { releaseAllPromptAsyncReservationsForTesting } from "../../shared/prompt-async-gate"
|
||||||
|
|
||||||
type RecoverableInfo = Parameters<ReturnType<typeof createSessionRecoveryHook>["handleSessionRecovery"]>[0]
|
type RecoverableInfo = Parameters<ReturnType<typeof createSessionRecoveryHook>["handleSessionRecovery"]>[0]
|
||||||
@@ -19,6 +20,7 @@ type PromptAsyncCall = {
|
|||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
releaseAllPromptAsyncReservationsForTesting()
|
releaseAllPromptAsyncReservationsForTesting()
|
||||||
|
_setInterruptedIdleMessagesFetchTimeoutMsForTesting(undefined)
|
||||||
})
|
})
|
||||||
|
|
||||||
function createPrefillErrorInfo(): RecoverableInfo {
|
function createPrefillErrorInfo(): RecoverableInfo {
|
||||||
@@ -127,7 +129,8 @@ describe("session-recovery hook interrupted idle recovery", () => {
|
|||||||
id: "msg_assistant_unfinished",
|
id: "msg_assistant_unfinished",
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
sessionID: "ses_idle_interrupted",
|
sessionID: "ses_idle_interrupted",
|
||||||
time: { created: 1778995446058 },
|
finish: "tool-calls",
|
||||||
|
time: { created: 1778995446058, completed: 1778995447058 },
|
||||||
},
|
},
|
||||||
parts: [
|
parts: [
|
||||||
{
|
{
|
||||||
@@ -186,4 +189,25 @@ describe("session-recovery hook interrupted idle recovery", () => {
|
|||||||
expect(promptAsyncCalls[0]?.body.model).toEqual({ providerID: "anthropic", modelID: "claude-opus-4-7" })
|
expect(promptAsyncCalls[0]?.body.model).toEqual({ providerID: "anthropic", modelID: "claude-opus-4-7" })
|
||||||
expect(promptAsyncCalls[0]?.body.variant).toBe("max")
|
expect(promptAsyncCalls[0]?.body.variant).toBe("max")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("#given session.messages hangs during idle recovery #when timeout elapses #then idle recovery returns false", async () => {
|
||||||
|
// given
|
||||||
|
_setInterruptedIdleMessagesFetchTimeoutMsForTesting(5)
|
||||||
|
const ctx = {
|
||||||
|
client: {
|
||||||
|
session: {
|
||||||
|
messages: async () => new Promise(() => {}),
|
||||||
|
promptAsync: async () => ({}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
directory: "/tmp/session-recovery-timeout-test",
|
||||||
|
}
|
||||||
|
const hook = createSessionRecoveryHook(ctx as never)
|
||||||
|
|
||||||
|
// when
|
||||||
|
const result = await hook.handleInterruptedToolResultsOnIdle("ses_messages_hangs")
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(result).toBe(false)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ import { detectErrorType } from "./detect-error-type"
|
|||||||
import type { RecoveryErrorType } from "./detect-error-type"
|
import type { RecoveryErrorType } from "./detect-error-type"
|
||||||
import type { MessageData } from "./types"
|
import type { MessageData } from "./types"
|
||||||
import { normalizeSDKResponse } from "../../shared"
|
import { normalizeSDKResponse } from "../../shared"
|
||||||
|
import {
|
||||||
|
getInterruptedIdleMessagesFetchTimeoutMs,
|
||||||
|
withInterruptedIdleMessagesFetchTimeout,
|
||||||
|
} from "./interrupted-idle-message-fetch-timeout"
|
||||||
import { recoverToolResultMissing } from "./recover-tool-result-missing"
|
import { recoverToolResultMissing } from "./recover-tool-result-missing"
|
||||||
import { recoverUnavailableTool } from "./recover-unavailable-tool"
|
import { recoverUnavailableTool } from "./recover-unavailable-tool"
|
||||||
import { recoverThinkingBlockOrder } from "./recover-thinking-block-order"
|
import { recoverThinkingBlockOrder } from "./recover-thinking-block-order"
|
||||||
@@ -56,6 +60,9 @@ export function createSessionRecoveryHook(ctx: PluginInput, options?: SessionRec
|
|||||||
}
|
}
|
||||||
|
|
||||||
const finish = message.info?.finish
|
const finish = message.info?.finish
|
||||||
|
if (finish === "tool-calls") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
if ((typeof finish === "string" && finish.length > 0) || finish === true) {
|
if ((typeof finish === "string" && finish.length > 0) || finish === true) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -99,10 +106,13 @@ export function createSessionRecoveryHook(ctx: PluginInput, options?: SessionRec
|
|||||||
let recoveryStarted = false
|
let recoveryStarted = false
|
||||||
let assistantMessageIDForRecovery: string | undefined
|
let assistantMessageIDForRecovery: string | undefined
|
||||||
try {
|
try {
|
||||||
const messagesResp = await ctx.client.session.messages({
|
const messagesResp = await withInterruptedIdleMessagesFetchTimeout(
|
||||||
path: { id: sessionID },
|
ctx.client.session.messages({
|
||||||
query: { directory: ctx.directory },
|
path: { id: sessionID },
|
||||||
})
|
query: { directory: ctx.directory },
|
||||||
|
}),
|
||||||
|
getInterruptedIdleMessagesFetchTimeoutMs(),
|
||||||
|
)
|
||||||
const messages = normalizeSDKResponse(messagesResp, [] as MessageData[])
|
const messages = normalizeSDKResponse(messagesResp, [] as MessageData[])
|
||||||
const latestAssistant = findLatestAssistantMessage(messages)
|
const latestAssistant = findLatestAssistantMessage(messages)
|
||||||
if (!latestAssistant?.info?.id) {
|
if (!latestAssistant?.info?.id) {
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
export const DEFAULT_INTERRUPTED_IDLE_MESSAGES_FETCH_TIMEOUT_MS = 5_000
|
||||||
|
|
||||||
|
let interruptedIdleMessagesFetchTimeoutMsForTesting: number | undefined
|
||||||
|
|
||||||
|
export function _setInterruptedIdleMessagesFetchTimeoutMsForTesting(value: number | undefined): void {
|
||||||
|
interruptedIdleMessagesFetchTimeoutMsForTesting = value
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getInterruptedIdleMessagesFetchTimeoutMs(): number {
|
||||||
|
return interruptedIdleMessagesFetchTimeoutMsForTesting ?? DEFAULT_INTERRUPTED_IDLE_MESSAGES_FETCH_TIMEOUT_MS
|
||||||
|
}
|
||||||
|
|
||||||
|
export class InterruptedIdleMessagesFetchTimeoutError extends Error {
|
||||||
|
constructor(timeoutMs: number) {
|
||||||
|
super(`[session-recovery] session.messages timed out after ${timeoutMs}ms while checking interrupted idle tools`)
|
||||||
|
this.name = "InterruptedIdleMessagesFetchTimeoutError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function withInterruptedIdleMessagesFetchTimeout<T>(operation: Promise<T>, timeoutMs: number): Promise<T> {
|
||||||
|
if (timeoutMs <= 0) {
|
||||||
|
return operation
|
||||||
|
}
|
||||||
|
|
||||||
|
let timeoutID: ReturnType<typeof globalThis.setTimeout> | undefined
|
||||||
|
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||||
|
timeoutID = globalThis.setTimeout(
|
||||||
|
() => reject(new InterruptedIdleMessagesFetchTimeoutError(timeoutMs)),
|
||||||
|
timeoutMs,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
return Promise.race([operation, timeoutPromise]).finally(() => {
|
||||||
|
if (timeoutID !== undefined) {
|
||||||
|
globalThis.clearTimeout(timeoutID)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -174,6 +174,7 @@ export async function recoverToolResultMissing(
|
|||||||
sessionID,
|
sessionID,
|
||||||
source: options?.source ?? "session-recovery-tool-result-missing",
|
source: options?.source ?? "session-recovery-tool-result-missing",
|
||||||
input: promptInput,
|
input: promptInput,
|
||||||
|
checkToolState: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
return promptResult.status === "dispatched"
|
return promptResult.status === "dispatched"
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { afterEach, describe, expect, test } from "bun:test"
|
import { afterEach, describe, expect, test } from "bun:test"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
_setPromptGateMessagesFetchTimeoutMsForTesting,
|
||||||
promptAfterSessionIdle,
|
promptAfterSessionIdle,
|
||||||
promptAsyncAfterSessionIdle,
|
promptAsyncAfterSessionIdle,
|
||||||
releaseAllPromptAsyncReservationsForTesting,
|
releaseAllPromptAsyncReservationsForTesting,
|
||||||
@@ -148,6 +149,109 @@ describe("promptAsyncAfterSessionIdle", () => {
|
|||||||
expect(promptCalls).toBe(0)
|
expect(promptCalls).toBe(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("#given latest assistant turn is waiting on tools #when an internal promptAsync is requested #then no prompt is sent", async () => {
|
||||||
|
// given
|
||||||
|
let promptCalls = 0
|
||||||
|
const client = {
|
||||||
|
session: {
|
||||||
|
status: async () => ({ data: { ses_waiting_tools: { type: "idle" } } }),
|
||||||
|
messages: async () => ({
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
info: { id: "msg_user", role: "user" },
|
||||||
|
parts: [{ type: "text", text: "run work" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
info: { id: "msg_assistant", role: "assistant", finish: "tool-calls" },
|
||||||
|
parts: [{ type: "tool_use", id: "toolu_pending", state: { status: "pending" } }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
promptAsync: async () => {
|
||||||
|
promptCalls += 1
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// when
|
||||||
|
const result = await promptAsyncAfterSessionIdle({
|
||||||
|
client,
|
||||||
|
sessionID: "ses_waiting_tools",
|
||||||
|
input: { path: { id: "ses_waiting_tools" }, body: { parts: [] } },
|
||||||
|
source: "test:waiting-tools",
|
||||||
|
settleMs: 0,
|
||||||
|
postDispatchHoldMs: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(result.status).toBe("active")
|
||||||
|
expect(promptCalls).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("#given latest assistant turn is waiting on tools #when tool-state check is disabled #then promptAsync is sent", async () => {
|
||||||
|
// given
|
||||||
|
let promptCalls = 0
|
||||||
|
const client = {
|
||||||
|
session: {
|
||||||
|
status: async () => ({ data: { ses_recovery_tools: { type: "idle" } } }),
|
||||||
|
messages: async () => ({
|
||||||
|
data: [{
|
||||||
|
info: { id: "msg_assistant", role: "assistant", finish: "tool-calls" },
|
||||||
|
parts: [{ type: "tool_use", id: "toolu_pending", state: { status: "pending" } }],
|
||||||
|
}],
|
||||||
|
}),
|
||||||
|
promptAsync: async () => {
|
||||||
|
promptCalls += 1
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// when
|
||||||
|
const result = await promptAsyncAfterSessionIdle({
|
||||||
|
client,
|
||||||
|
sessionID: "ses_recovery_tools",
|
||||||
|
input: { path: { id: "ses_recovery_tools" }, body: { parts: [] } },
|
||||||
|
source: "test:recovery-tools",
|
||||||
|
settleMs: 0,
|
||||||
|
postDispatchHoldMs: 0,
|
||||||
|
checkToolState: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(result.status).toBe("dispatched")
|
||||||
|
expect(promptCalls).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("#given latest-message fetch hangs #when an internal promptAsync is requested #then the tool-state check times out and dispatch continues", async () => {
|
||||||
|
// given
|
||||||
|
_setPromptGateMessagesFetchTimeoutMsForTesting(5)
|
||||||
|
let promptCalls = 0
|
||||||
|
const client = {
|
||||||
|
session: {
|
||||||
|
status: async () => ({ data: { ses_messages_hang: { type: "idle" } } }),
|
||||||
|
messages: async () => new Promise(() => {}),
|
||||||
|
promptAsync: async () => {
|
||||||
|
promptCalls += 1
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// when
|
||||||
|
const result = await promptAsyncAfterSessionIdle({
|
||||||
|
client,
|
||||||
|
sessionID: "ses_messages_hang",
|
||||||
|
input: { path: { id: "ses_messages_hang" }, body: { parts: [] } },
|
||||||
|
source: "test:messages-hang",
|
||||||
|
settleMs: 0,
|
||||||
|
postDispatchHoldMs: 0,
|
||||||
|
dispatchTimeoutMs: 50,
|
||||||
|
})
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(result.status).toBe("dispatched")
|
||||||
|
expect(promptCalls).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
test("#given dispatch hold has expired #when the same session prompts again #then the next promptAsync is accepted", async () => {
|
test("#given dispatch hold has expired #when the same session prompts again #then the next promptAsync is accepted", async () => {
|
||||||
// given
|
// given
|
||||||
let promptCalls = 0
|
let promptCalls = 0
|
||||||
|
|||||||
@@ -408,6 +408,46 @@ describe("createEventHandler - idle deduplication", () => {
|
|||||||
expect(callOrder).toEqual(["sessionRecovery"])
|
expect(callOrder).toEqual(["sessionRecovery"])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it("#given idle recovery handles an interrupted tool turn #when session.status normalizes to idle #then synthetic idle hooks are skipped", async () => {
|
||||||
|
const callOrder: string[] = []
|
||||||
|
const eventHandler = createEventHandler({
|
||||||
|
ctx: asEventHandlerContext({ directory: "/tmp" }),
|
||||||
|
pluginConfig: asPluginConfig({}),
|
||||||
|
firstMessageVariantGate: {
|
||||||
|
markSessionCreated: () => {},
|
||||||
|
clear: () => {},
|
||||||
|
},
|
||||||
|
managers: createEventHandlerManagers(),
|
||||||
|
hooks: createEventHandlerHooks({
|
||||||
|
sessionRecovery: {
|
||||||
|
handleInterruptedToolResultsOnIdle: async () => {
|
||||||
|
callOrder.push("sessionRecovery")
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
},
|
||||||
|
todoContinuationEnforcer: {
|
||||||
|
handler: async (input: EventInput) => {
|
||||||
|
if (input.event.type === "session.idle") {
|
||||||
|
callOrder.push("todoContinuationEnforcer")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
await eventHandler(asEventHandlerInput({
|
||||||
|
event: {
|
||||||
|
type: "session.status",
|
||||||
|
properties: {
|
||||||
|
sessionID: "ses_interrupted_status_idle",
|
||||||
|
status: { type: "idle" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
expect(callOrder).toEqual(["sessionRecovery"])
|
||||||
|
})
|
||||||
|
|
||||||
it("keeps other session dedup state untouched when bypassing synthetic-idle for current session", async () => {
|
it("keeps other session dedup state untouched when bypassing synthetic-idle for current session", async () => {
|
||||||
//#given
|
//#given
|
||||||
const originalDateNow = Date.now
|
const originalDateNow = Date.now
|
||||||
|
|||||||
+30
-17
@@ -377,6 +377,19 @@ export function createEventHandler(args: {
|
|||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const recoverInterruptedToolResultsOnIdleEvent = async (input: EventInput): Promise<boolean> => {
|
||||||
|
if (input.event.type !== "session.idle") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionID = getEventSessionID(input);
|
||||||
|
if (!sessionID || !hooks.sessionRecovery?.handleInterruptedToolResultsOnIdle) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return hooks.sessionRecovery.handleInterruptedToolResultsOnIdle(sessionID);
|
||||||
|
};
|
||||||
|
|
||||||
const getFallbackContinuationKeys = (fallbackContext?: FallbackContinuationContext): FallbackContinuationDedupeKeys => {
|
const getFallbackContinuationKeys = (fallbackContext?: FallbackContinuationContext): FallbackContinuationDedupeKeys => {
|
||||||
const agentKey = fallbackContext?.agentName
|
const agentKey = fallbackContext?.agentName
|
||||||
? getAgentConfigKey(fallbackContext.agentName).trim().toLowerCase()
|
? getAgentConfigKey(fallbackContext.agentName).trim().toLowerCase()
|
||||||
@@ -572,12 +585,9 @@ export function createEventHandler(args: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (input.event.type === "session.idle") {
|
if (input.event.type === "session.idle") {
|
||||||
const sessionID = getEventSessionID(input);
|
const recovered = await recoverInterruptedToolResultsOnIdleEvent(input);
|
||||||
if (sessionID && hooks.sessionRecovery?.handleInterruptedToolResultsOnIdle) {
|
if (recovered) {
|
||||||
const recovered = await hooks.sessionRecovery.handleInterruptedToolResultsOnIdle(sessionID);
|
return;
|
||||||
if (recovered) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -596,17 +606,20 @@ export function createEventHandler(args: {
|
|||||||
if (!shouldDispatchIdleEvent(sessionID, now)) {
|
if (!shouldDispatchIdleEvent(sessionID, now)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await dispatchToHooks(syntheticIdle as EventInput);
|
const recovered = await recoverInterruptedToolResultsOnIdleEvent(syntheticIdle as EventInput);
|
||||||
if (pluginConfig.openclaw) {
|
if (!recovered) {
|
||||||
await dispatchOpenClawEvent({
|
await dispatchToHooks(syntheticIdle as EventInput);
|
||||||
config: pluginConfig.openclaw,
|
if (pluginConfig.openclaw) {
|
||||||
rawEvent: "session.idle",
|
await dispatchOpenClawEvent({
|
||||||
context: {
|
config: pluginConfig.openclaw,
|
||||||
sessionId: sessionID,
|
rawEvent: "session.idle",
|
||||||
projectPath: pluginContext.directory,
|
context: {
|
||||||
tmuxPaneId: managers.tmuxSessionManager.getTrackedPaneId?.(sessionID) ?? process.env.TMUX_PANE,
|
sessionId: sessionID,
|
||||||
},
|
projectPath: pluginContext.directory,
|
||||||
});
|
tmuxPaneId: managers.tmuxSessionManager.getTrackedPaneId?.(sessionID) ?? process.env.TMUX_PANE,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
|
|
||||||
export const DEFAULT_PROMPT_ASYNC_POST_DISPATCH_HOLD_MS = 250
|
export const DEFAULT_PROMPT_ASYNC_POST_DISPATCH_HOLD_MS = 250
|
||||||
export const DEFAULT_PROMPT_DISPATCH_TIMEOUT_MS = 30_000
|
export const DEFAULT_PROMPT_DISPATCH_TIMEOUT_MS = 30_000
|
||||||
|
export const DEFAULT_PROMPT_GATE_MESSAGES_FETCH_TIMEOUT_MS = 5_000
|
||||||
|
|
||||||
type PromptAsyncInput = {
|
type PromptAsyncInput = {
|
||||||
path?: { id?: string }
|
path?: { id?: string }
|
||||||
@@ -16,9 +17,15 @@ type PromptAsyncInput = {
|
|||||||
[key: string]: unknown
|
[key: string]: unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PromptMessagesQuery = {
|
||||||
|
directory: string
|
||||||
|
limit?: number
|
||||||
|
}
|
||||||
|
|
||||||
type PromptAsyncClient<TInput> = {
|
type PromptAsyncClient<TInput> = {
|
||||||
session?: {
|
session?: {
|
||||||
status?: () => Promise<unknown>
|
status?: () => Promise<unknown>
|
||||||
|
messages?: (input: { path: { id: string }; query: PromptMessagesQuery }) => Promise<unknown>
|
||||||
promptAsync?: (input: TInput) => Promise<unknown>
|
promptAsync?: (input: TInput) => Promise<unknown>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -26,6 +33,7 @@ type PromptAsyncClient<TInput> = {
|
|||||||
type PromptClient<TInput> = {
|
type PromptClient<TInput> = {
|
||||||
session?: {
|
session?: {
|
||||||
status?: () => Promise<unknown>
|
status?: () => Promise<unknown>
|
||||||
|
messages?: (input: { path: { id: string }; query: PromptMessagesQuery }) => Promise<unknown>
|
||||||
prompt?: (input: TInput) => Promise<unknown>
|
prompt?: (input: TInput) => Promise<unknown>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -40,6 +48,8 @@ type PromptAsyncReservation = {
|
|||||||
declare function setTimeout(callback: () => void, delay?: number): ReturnType<typeof globalThis.setTimeout>
|
declare function setTimeout(callback: () => void, delay?: number): ReturnType<typeof globalThis.setTimeout>
|
||||||
declare function clearTimeout(timeout: ReturnType<typeof globalThis.setTimeout>): void
|
declare function clearTimeout(timeout: ReturnType<typeof globalThis.setTimeout>): void
|
||||||
|
|
||||||
|
let promptGateMessagesFetchTimeoutMsForTesting: number | undefined
|
||||||
|
|
||||||
export type PromptAsyncGateResult =
|
export type PromptAsyncGateResult =
|
||||||
| { status: "dispatched"; response: unknown }
|
| { status: "dispatched"; response: unknown }
|
||||||
| { status: "active" }
|
| { status: "active" }
|
||||||
@@ -54,6 +64,14 @@ type PromptAsyncReservationReleaseOptions = {
|
|||||||
|
|
||||||
const promptAsyncReservations = new Map<string, PromptAsyncReservation>()
|
const promptAsyncReservations = new Map<string, PromptAsyncReservation>()
|
||||||
|
|
||||||
|
export function _setPromptGateMessagesFetchTimeoutMsForTesting(value: number | undefined): void {
|
||||||
|
promptGateMessagesFetchTimeoutMsForTesting = value
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPromptGateMessagesFetchTimeoutMs(): number {
|
||||||
|
return promptGateMessagesFetchTimeoutMsForTesting ?? DEFAULT_PROMPT_GATE_MESSAGES_FETCH_TIMEOUT_MS
|
||||||
|
}
|
||||||
|
|
||||||
function pruneExpiredReservations(now = Date.now()): void {
|
function pruneExpiredReservations(now = Date.now()): void {
|
||||||
for (const [sessionID, reservation] of promptAsyncReservations) {
|
for (const [sessionID, reservation] of promptAsyncReservations) {
|
||||||
if (typeof reservation.expiresAt === "number" && reservation.expiresAt <= now) {
|
if (typeof reservation.expiresAt === "number" && reservation.expiresAt <= now) {
|
||||||
@@ -119,9 +137,120 @@ async function withDispatchTimeout<T>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPromptQuery(input: unknown): PromptMessagesQuery {
|
||||||
|
if (!isRecord(input)) {
|
||||||
|
return { directory: "" }
|
||||||
|
}
|
||||||
|
const query = input.query
|
||||||
|
if (!isRecord(query)) {
|
||||||
|
return { directory: "" }
|
||||||
|
}
|
||||||
|
|
||||||
|
const promptQuery: PromptMessagesQuery = { directory: "" }
|
||||||
|
if (typeof query.directory === "string") {
|
||||||
|
promptQuery.directory = query.directory
|
||||||
|
}
|
||||||
|
if (typeof query.limit === "number") {
|
||||||
|
promptQuery.limit = query.limit
|
||||||
|
}
|
||||||
|
return promptQuery
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMessagesData(response: unknown): unknown[] {
|
||||||
|
if (isRecord(response) && Array.isArray(response.data)) {
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
return Array.isArray(response) ? response : []
|
||||||
|
}
|
||||||
|
|
||||||
|
function messageRole(message: unknown): string | undefined {
|
||||||
|
if (!isRecord(message)) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
const info = message.info
|
||||||
|
if (isRecord(info) && typeof info.role === "string") {
|
||||||
|
return info.role
|
||||||
|
}
|
||||||
|
return typeof message.role === "string" ? message.role : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function partIsWaitingOnTool(part: unknown): boolean {
|
||||||
|
if (!isRecord(part)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (part.type !== "tool" && part.type !== "tool_use") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = part.state
|
||||||
|
if (!isRecord(state)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return state.status === "pending" || state.status === "running"
|
||||||
|
}
|
||||||
|
|
||||||
|
function latestAssistantTurnIsWaitingOnTools(messages: unknown[]): boolean {
|
||||||
|
for (let index = messages.length - 1; index >= 0; index--) {
|
||||||
|
const message = messages[index]
|
||||||
|
const role = messageRole(message)
|
||||||
|
if (role === "assistant") {
|
||||||
|
if (!isRecord(message) || !Array.isArray(message.parts)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return message.parts.some(partIsWaitingOnTool)
|
||||||
|
}
|
||||||
|
if (role === "user") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sessionLatestAssistantIsWaitingOnTools<TInput>(args: {
|
||||||
|
client: { session?: { messages?: (input: { path: { id: string }; query: PromptMessagesQuery }) => Promise<unknown> } }
|
||||||
|
sessionID: string
|
||||||
|
input: TInput
|
||||||
|
sessionName: "promptAsync" | "prompt"
|
||||||
|
source: string
|
||||||
|
timeoutMs: number
|
||||||
|
}): Promise<boolean> {
|
||||||
|
const messages = args.client.session?.messages
|
||||||
|
if (typeof messages !== "function") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await withDispatchTimeout(
|
||||||
|
messages({
|
||||||
|
path: { id: args.sessionID },
|
||||||
|
query: getPromptQuery(args.input),
|
||||||
|
}),
|
||||||
|
args.timeoutMs,
|
||||||
|
`[prompt-async-gate] ${args.sessionName} session.messages`,
|
||||||
|
)
|
||||||
|
return latestAssistantTurnIsWaitingOnTools(getMessagesData(response))
|
||||||
|
} catch (error) {
|
||||||
|
log("[prompt-async-gate] latest assistant tool-state check failed", {
|
||||||
|
sessionID: args.sessionID,
|
||||||
|
source: args.source,
|
||||||
|
error: String(error),
|
||||||
|
})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function dispatchAfterSessionIdle<TInput>(args: {
|
async function dispatchAfterSessionIdle<TInput>(args: {
|
||||||
sessionName: "promptAsync" | "prompt"
|
sessionName: "promptAsync" | "prompt"
|
||||||
client: { session?: { status?: () => Promise<unknown> } }
|
client: {
|
||||||
|
session?: {
|
||||||
|
status?: () => Promise<unknown>
|
||||||
|
messages?: (input: { path: { id: string }; query: PromptMessagesQuery }) => Promise<unknown>
|
||||||
|
}
|
||||||
|
}
|
||||||
sessionID: string
|
sessionID: string
|
||||||
input: TInput
|
input: TInput
|
||||||
source: string
|
source: string
|
||||||
@@ -129,6 +258,7 @@ async function dispatchAfterSessionIdle<TInput>(args: {
|
|||||||
postDispatchHoldMs: number
|
postDispatchHoldMs: number
|
||||||
dispatchTimeoutMs: number
|
dispatchTimeoutMs: number
|
||||||
checkStatus: boolean
|
checkStatus: boolean
|
||||||
|
checkToolState: boolean
|
||||||
dispatch: (input: TInput) => Promise<unknown>
|
dispatch: (input: TInput) => Promise<unknown>
|
||||||
}): Promise<PromptAsyncGateResult> {
|
}): Promise<PromptAsyncGateResult> {
|
||||||
const {
|
const {
|
||||||
@@ -141,6 +271,7 @@ async function dispatchAfterSessionIdle<TInput>(args: {
|
|||||||
postDispatchHoldMs,
|
postDispatchHoldMs,
|
||||||
dispatchTimeoutMs,
|
dispatchTimeoutMs,
|
||||||
checkStatus,
|
checkStatus,
|
||||||
|
checkToolState,
|
||||||
dispatch,
|
dispatch,
|
||||||
} = args
|
} = args
|
||||||
|
|
||||||
@@ -186,6 +317,25 @@ async function dispatchAfterSessionIdle<TInput>(args: {
|
|||||||
return { status: "active" }
|
return { status: "active" }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
checkToolState
|
||||||
|
&& typeof client.session?.messages === "function"
|
||||||
|
&& await sessionLatestAssistantIsWaitingOnTools({
|
||||||
|
client,
|
||||||
|
sessionID,
|
||||||
|
input,
|
||||||
|
sessionName,
|
||||||
|
source,
|
||||||
|
timeoutMs: Math.min(dispatchTimeoutMs, getPromptGateMessagesFetchTimeoutMs()),
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
log(`[prompt-async-gate] ${sessionName} skipped because latest assistant is waiting on tools`, {
|
||||||
|
sessionID,
|
||||||
|
source,
|
||||||
|
})
|
||||||
|
return { status: "active" }
|
||||||
|
}
|
||||||
|
|
||||||
log(`[prompt-async-gate] ${sessionName} dispatching`, { sessionID, source })
|
log(`[prompt-async-gate] ${sessionName} dispatching`, { sessionID, source })
|
||||||
dispatchAttempted = true
|
dispatchAttempted = true
|
||||||
const response = await withDispatchTimeout(
|
const response = await withDispatchTimeout(
|
||||||
@@ -219,6 +369,7 @@ export async function promptAsyncAfterSessionIdle<TInput = PromptAsyncInput>(arg
|
|||||||
postDispatchHoldMs?: number
|
postDispatchHoldMs?: number
|
||||||
dispatchTimeoutMs?: number
|
dispatchTimeoutMs?: number
|
||||||
checkStatus?: boolean
|
checkStatus?: boolean
|
||||||
|
checkToolState?: boolean
|
||||||
}): Promise<PromptAsyncGateResult> {
|
}): Promise<PromptAsyncGateResult> {
|
||||||
const {
|
const {
|
||||||
client,
|
client,
|
||||||
@@ -247,6 +398,7 @@ export async function promptAsyncAfterSessionIdle<TInput = PromptAsyncInput>(arg
|
|||||||
postDispatchHoldMs,
|
postDispatchHoldMs,
|
||||||
dispatchTimeoutMs,
|
dispatchTimeoutMs,
|
||||||
checkStatus: args.checkStatus !== false,
|
checkStatus: args.checkStatus !== false,
|
||||||
|
checkToolState: args.checkToolState !== false,
|
||||||
dispatch: (dispatchInput) => dispatchPromptAsync(dispatchInput),
|
dispatch: (dispatchInput) => dispatchPromptAsync(dispatchInput),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -260,6 +412,7 @@ export async function promptAfterSessionIdle<TInput = PromptAsyncInput>(args: {
|
|||||||
postDispatchHoldMs?: number
|
postDispatchHoldMs?: number
|
||||||
dispatchTimeoutMs?: number
|
dispatchTimeoutMs?: number
|
||||||
checkStatus?: boolean
|
checkStatus?: boolean
|
||||||
|
checkToolState?: boolean
|
||||||
}): Promise<PromptAsyncGateResult> {
|
}): Promise<PromptAsyncGateResult> {
|
||||||
const {
|
const {
|
||||||
client,
|
client,
|
||||||
@@ -288,12 +441,14 @@ export async function promptAfterSessionIdle<TInput = PromptAsyncInput>(args: {
|
|||||||
postDispatchHoldMs,
|
postDispatchHoldMs,
|
||||||
dispatchTimeoutMs,
|
dispatchTimeoutMs,
|
||||||
checkStatus: args.checkStatus !== false,
|
checkStatus: args.checkStatus !== false,
|
||||||
|
checkToolState: args.checkToolState !== false,
|
||||||
dispatch: (dispatchInput) => dispatchPrompt(dispatchInput),
|
dispatch: (dispatchInput) => dispatchPrompt(dispatchInput),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function releaseAllPromptAsyncReservationsForTesting(): void {
|
export function releaseAllPromptAsyncReservationsForTesting(): void {
|
||||||
promptAsyncReservations.clear()
|
promptAsyncReservations.clear()
|
||||||
|
promptGateMessagesFetchTimeoutMsForTesting = undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
export function releasePromptAsyncReservation(
|
export function releasePromptAsyncReservation(
|
||||||
|
|||||||
Reference in New Issue
Block a user