fix(prompt-gate): block prompts into pending tool turns

This commit is contained in:
YeonGyu-Kim
2026-05-17 15:42:58 +09:00
parent 6eb88a0545
commit a7b7ace7ed
9 changed files with 454 additions and 23 deletions
+40
View File
@@ -408,6 +408,46 @@ describe("createEventHandler - idle deduplication", () => {
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 () => {
//#given
const originalDateNow = Date.now
+30 -17
View File
@@ -377,6 +377,19 @@ export function createEventHandler(args: {
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 agentKey = fallbackContext?.agentName
? getAgentConfigKey(fallbackContext.agentName).trim().toLowerCase()
@@ -572,12 +585,9 @@ export function createEventHandler(args: {
}
if (input.event.type === "session.idle") {
const sessionID = getEventSessionID(input);
if (sessionID && hooks.sessionRecovery?.handleInterruptedToolResultsOnIdle) {
const recovered = await hooks.sessionRecovery.handleInterruptedToolResultsOnIdle(sessionID);
if (recovered) {
return;
}
const recovered = await recoverInterruptedToolResultsOnIdleEvent(input);
if (recovered) {
return;
}
}
@@ -596,17 +606,20 @@ export function createEventHandler(args: {
if (!shouldDispatchIdleEvent(sessionID, now)) {
return;
}
await dispatchToHooks(syntheticIdle as EventInput);
if (pluginConfig.openclaw) {
await dispatchOpenClawEvent({
config: pluginConfig.openclaw,
rawEvent: "session.idle",
context: {
sessionId: sessionID,
projectPath: pluginContext.directory,
tmuxPaneId: managers.tmuxSessionManager.getTrackedPaneId?.(sessionID) ?? process.env.TMUX_PANE,
},
});
const recovered = await recoverInterruptedToolResultsOnIdleEvent(syntheticIdle as EventInput);
if (!recovered) {
await dispatchToHooks(syntheticIdle as EventInput);
if (pluginConfig.openclaw) {
await dispatchOpenClawEvent({
config: pluginConfig.openclaw,
rawEvent: "session.idle",
context: {
sessionId: sessionID,
projectPath: pluginContext.directory,
tmuxPaneId: managers.tmuxSessionManager.getTrackedPaneId?.(sessionID) ?? process.env.TMUX_PANE,
},
});
}
}
}