From cf9be6014524c66a09129e328bf3652d4e85b1cc Mon Sep 17 00:00:00 2001 From: zhi Date: Mon, 2 Mar 2026 11:20:39 +0000 Subject: [PATCH] fix: advance turn in before_message_write to prevent race condition When a speaker finishes with an end symbol, the turn was only advanced in the message_sent hook. But by that time, the message had already been broadcast to other agents, whose before_model_resolve ran with the old turn state, causing them to be blocked by the turn gate (forced no-reply). Fix: - Move turn advance for both NO_REPLY and end-symbol cases to before_message_write, which fires before the message is broadcast. - Guard 1: Only the current speaker can advance the turn (accountId check). - Guard 2: Only process assistant messages (role check). before_message_write fires for incoming user messages too, which contain end symbols from other agents and would cause cascading turn advances. - Use sessionTurnHandled set to prevent double-advancing in message_sent. --- dist/whispergate/index.ts | 16 ++++++++++++++++ plugin/index.ts | 16 ++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/dist/whispergate/index.ts b/dist/whispergate/index.ts index a4b507d..d838633 100644 --- a/dist/whispergate/index.ts +++ b/dist/whispergate/index.ts @@ -932,9 +932,14 @@ export default { } // Extract content from event.message (AgentMessage) + // Only process assistant messages — before_message_write fires for both + // user (incoming) and assistant (outgoing) messages. Incoming messages may + // contain end symbols from OTHER agents, which would incorrectly advance the turn. let content = ""; const msg = (event as Record).message as Record | undefined; if (msg) { + const role = msg.role as string | undefined; + if (role && role !== "assistant") return; // AgentMessage may have content as string or nested if (typeof msg.content === "string") { content = msg.content; @@ -960,6 +965,17 @@ export default { if (!key || !channelId || !accountId) return; + // Only the current speaker should advance the turn. + // Other agents also trigger before_message_write (for incoming messages or forced no-reply), + // but they must not affect turn state. + const currentTurn = getTurnDebugInfo(channelId); + if (currentTurn.currentSpeaker !== accountId) { + api.logger.info( + `whispergate: before_message_write skipping non-current-speaker session=${key} accountId=${accountId} currentSpeaker=${currentTurn.currentSpeaker}`, + ); + return; + } + const live = getLivePluginConfig(api, baseConfig as WhisperGateConfig) as WhisperGateConfig & DebugConfig; ensurePolicyStateLoaded(api, live); const policy = resolvePolicy(live, channelId, policyState.channelPolicies); diff --git a/plugin/index.ts b/plugin/index.ts index a4b507d..d838633 100644 --- a/plugin/index.ts +++ b/plugin/index.ts @@ -932,9 +932,14 @@ export default { } // Extract content from event.message (AgentMessage) + // Only process assistant messages — before_message_write fires for both + // user (incoming) and assistant (outgoing) messages. Incoming messages may + // contain end symbols from OTHER agents, which would incorrectly advance the turn. let content = ""; const msg = (event as Record).message as Record | undefined; if (msg) { + const role = msg.role as string | undefined; + if (role && role !== "assistant") return; // AgentMessage may have content as string or nested if (typeof msg.content === "string") { content = msg.content; @@ -960,6 +965,17 @@ export default { if (!key || !channelId || !accountId) return; + // Only the current speaker should advance the turn. + // Other agents also trigger before_message_write (for incoming messages or forced no-reply), + // but they must not affect turn state. + const currentTurn = getTurnDebugInfo(channelId); + if (currentTurn.currentSpeaker !== accountId) { + api.logger.info( + `whispergate: before_message_write skipping non-current-speaker session=${key} accountId=${accountId} currentSpeaker=${currentTurn.currentSpeaker}`, + ); + return; + } + const live = getLivePluginConfig(api, baseConfig as WhisperGateConfig) as WhisperGateConfig & DebugConfig; ensurePolicyStateLoaded(api, live); const policy = resolvePolicy(live, channelId, policyState.channelPolicies);