fix(plugin): run idle hooks for synthetic status idle

OpenCode now treats session.status idle as the durable completion boundary, but the plugin only dispatched the synthetic session.idle through the main hook chain. Idle-only side effects such as tmux forwarding and team member idle continuations were skipped.

Route synthetic idle through the same idle-only hook path used by real session.idle events and pin the behavior with a regression test.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
YeonGyu-Kim
2026-05-20 14:34:42 +09:00
parent faf2b02163
commit 4a72729acc
2 changed files with 59 additions and 4 deletions
+49
View File
@@ -208,6 +208,55 @@ describe("createEventHandler - idle deduplication", () => {
expect(onEvent.mock.calls[0]?.[0]).toEqual(idleEvent.event)
})
it("#given tmux integration enabled #when session.status reports idle #then synthetic idle forwards to tmuxSessionManager.onEvent", async () => {
//#given
const onEvent = mock<(event: EventInput["event"]) => void>(() => {})
const eventHandler = createEventHandler({
ctx: asEventHandlerContext({
directory: "/tmp",
client: {
session: {},
},
}),
pluginConfig: asPluginConfig({
tmux: { enabled: true },
}),
firstMessageVariantGate: {
markSessionCreated: () => {},
clear: () => {},
},
managers: createEventHandlerManagers({
tmuxSessionManager: {
onEvent,
onSessionCreated: async () => {},
onSessionDeleted: async () => {},
},
}),
hooks: createEventHandlerHooks({}),
})
//#when
await eventHandler(asEventHandlerInput({
event: {
type: "session.status",
properties: {
sessionID: "ses_tmux_synthetic_idle",
status: { type: "idle" },
},
},
}))
//#then
expect(onEvent).toHaveBeenCalledTimes(1)
expect(onEvent.mock.calls[0]?.[0]).toEqual({
type: "session.idle",
properties: {
sessionID: "ses_tmux_synthetic_idle",
synthetic: true,
},
})
})
it("#given a readiness retry is pending #when session.idle arrives through the plugin handler #then tmux retry spawns the pane", async () => {
//#given
const sessionStatusData: Record<string, { type: string }> = {}
+10 -4
View File
@@ -394,6 +394,12 @@ export function createEventHandler(args: {
return hooks.sessionRecovery.handleInterruptedToolResultsOnIdle(sessionID);
};
const dispatchIdleOnlyHooks = async (input: EventInput): Promise<void> => {
managers.tmuxSessionManager?.onEvent?.(input.event);
await runEventHookSafely("teamIdleWakeHint", teamIdleWakeHint, input);
await runEventHookSafely("teamMemberStatusHandler", teamMemberStatusHandler, input);
};
const getFallbackContinuationKeys = (fallbackContext?: FallbackContinuationContext): FallbackContinuationDedupeKeys => {
const agentKey = fallbackContext?.agentName
? getAgentConfigKey(fallbackContext.agentName).trim().toLowerCase()
@@ -628,7 +634,8 @@ export function createEventHandler(args: {
if (!shouldDispatchIdleEvent(sessionID, now)) {
return;
}
await dispatchToHooks(syntheticIdle as EventInput);
const syntheticIdleInput = syntheticIdle as EventInput;
await dispatchToHooks(syntheticIdleInput);
if (pluginConfig.openclaw) {
await dispatchOpenClawEvent({
config: pluginConfig.openclaw,
@@ -640,6 +647,7 @@ export function createEventHandler(args: {
},
});
}
await dispatchIdleOnlyHooks(syntheticIdleInput);
}
const { event } = input;
@@ -761,9 +769,7 @@ export function createEventHandler(args: {
}
if (event.type === "session.idle") {
managers.tmuxSessionManager?.onEvent?.(event);
await runEventHookSafely("teamIdleWakeHint", teamIdleWakeHint, input);
await runEventHookSafely("teamMemberStatusHandler", teamMemberStatusHandler, input);
await dispatchIdleOnlyHooks(input);
}
if (event.type === "message.updated") {