From f57b51303c4c7fb3bc50023bd5f10a34d3ecae98 Mon Sep 17 00:00:00 2001 From: Ryan Schmukler Date: Sun, 7 Jun 2026 14:13:19 -0400 Subject: [PATCH] fix(chat): auto-dismiss pending ask_user question when another client answers In remote mode (eca 0.139.0) ask_user reaches the editor and connected SSE/web clients simultaneously, with the first answer winning. When another client answers first the server resolves the tool call and sends a toolCalled/toolCallRejected for that id, cancelling our request without expecting an answer. Correlate that id with eca-chat--pending-question and clear the stale answer-mode prompt state so this client doesn't stay stuck waiting for input. --- CHANGELOG.md | 3 ++- eca-chat.el | 21 +++++++++++++++++++++ test/eca-chat-test.el | 38 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 61 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 25fb6d3..105b2d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- Auto-dismiss a pending `ask_user` question when another client (e.g. an SSE/web client in remote mode, see eca 0.139.0) answers it first. The server resolves the `ask_user` tool out from under us and sends a `toolCalled`/`toolCallRejected` for that tool-call id but no longer expects our answer (it cancels our request); we now correlate that id with `eca-chat--pending-question` and clear the stale answer-mode prompt state instead of staying stuck waiting for input. - Add `eca-chat-delete` command to delete the active chat from the server without prompting. Works from any buffer in the project (acts on the session's last visited chat), switches the chat window to another chat first when one exists, and is bound to `C-c C-S-k` plus a `Delete` entry in the transient menu. - Bugfix: pressing `RET` on a URL in the chat now opens it even when the URL is wrapped in markdown emphasis like `**https://...**` or `_https://..._`. The face at point is a list in that case, so the old `eq` check missed it; the trailing `**`/`_` markers are also stripped so the right URL opens. Proper `[text](url)` links still go through `markdown-follow-thing-at-point`. - Bugfix: switching chat tabs via `tab-line-switch-to-next-tab`/`tab-line-switch-to-prev-tab` or clicking a tab now switches the chat in place instead of opening it in a new window. @@ -48,7 +49,7 @@ ## 0.6.0 -- Add `eca-chat-save-to-file` command. #95 +- Add `eca-chat-save-to-file` command. #95 - Fix chat not being closed. #89 - Fix: check existing eca sessions when opening chat. #88 - Add rewrite feature. diff --git a/eca-chat.el b/eca-chat.el index 4ba6042..4ab16ee 100644 --- a/eca-chat.el +++ b/eca-chat.el @@ -3128,6 +3128,9 @@ Must be called with `eca-chat--with-current-buffer' or equivalent." (remhash id eca-chat--tool-call-prepare-content-cache) ;; Stop elapsed-time tracking for this tool call (eca-chat--tool-call-elapsed-stop id) + ;; Another client may have answered this ask_user question first, + ;; resolving the tool call here; drop our now-stale prompt state. + (eca-chat--dismiss-pending-question-for-tool-call id) ;; Cleanup subagent mapping only for top-level tool calls (when (and (not parent-tool-call-id) (string= "subagent" (plist-get details :type))) @@ -3167,6 +3170,9 @@ Must be called with `eca-chat--with-current-buffer' or equivalent." ;; Cleanup counters for this tool-call id (remhash id eca-chat--tool-call-prepare-counters) (remhash id eca-chat--tool-call-prepare-content-cache) + ;; Another client may have cancelled this ask_user question first, + ;; rejecting the tool call here; drop our now-stale prompt state. + (eca-chat--dismiss-pending-question-for-tool-call id) ;; Cleanup subagent mapping only for top-level tool calls (when (and (not parent-tool-call-id) (string= "subagent" (plist-get details :type))) @@ -3620,6 +3626,21 @@ Used as fallback when no toolCallId is available." session request (list :answer nil :cancelled t))))) +(defun eca-chat--dismiss-pending-question-for-tool-call (tool-call-id) + "Dismiss the pending question when it belongs to TOOL-CALL-ID. +Used when another client answers the same `ask_user' question first, so +the server resolves the tool call out from under us. The tool output is +rendered by the caller; here we only restore the local question state +and prompt so this client does not stay stuck in answer mode." + (when (and eca-chat--pending-question + (equal tool-call-id + (plist-get eca-chat--pending-question :tool-call-id))) + (let ((allow-freeform (plist-get eca-chat--pending-question :allow-freeform))) + (setq eca-chat--pending-question nil) + (when allow-freeform + (eca-chat--set-question-prompt-prefix nil)) + (eca-chat--refresh-transient-area)))) + (defun eca-chat--set-question-prompt-prefix (active) "Toggle the prompt prefix for question mode. When ACTIVE is non-nil, show the question prefix; otherwise restore normal." diff --git a/test/eca-chat-test.el b/test/eca-chat-test.el index 029ab0a..ab90a82 100644 --- a/test/eca-chat-test.el +++ b/test/eca-chat-test.el @@ -269,6 +269,44 @@ does not treat the first line as metadata. Returns FN's value." (expect 'eca-chat--set-chat-loading :to-have-been-called-with session 'stopping))))) +(describe "eca-chat--dismiss-pending-question-for-tool-call" + ;; When another client answers/cancels the same `ask_user' question first, + ;; the server resolves the tool call and we receive a `toolCalled' / + ;; `toolCallRejected' for that id. This client must then drop its now-stale + ;; pending-question state so the prompt leaves answer mode. + (it "clears the pending question when the tool-call id matches" + (with-temp-buffer + (setq-local eca-chat--pending-question + (list :session (make-eca--session) :request 1 + :tool-call-id "tc-1" :allow-freeform t)) + (spy-on 'eca-chat--set-question-prompt-prefix) + (spy-on 'eca-chat--refresh-transient-area) + (eca-chat--dismiss-pending-question-for-tool-call "tc-1") + (expect eca-chat--pending-question :to-be nil) + (expect 'eca-chat--set-question-prompt-prefix + :to-have-been-called-with nil) + (expect 'eca-chat--refresh-transient-area :to-have-been-called))) + + (it "leaves the pending question intact when the id does not match" + (with-temp-buffer + (let ((pending (list :session (make-eca--session) :request 1 + :tool-call-id "tc-1" :allow-freeform t))) + (setq-local eca-chat--pending-question pending) + (spy-on 'eca-chat--set-question-prompt-prefix) + (spy-on 'eca-chat--refresh-transient-area) + (eca-chat--dismiss-pending-question-for-tool-call "tc-2") + (expect eca-chat--pending-question :to-equal pending) + (expect 'eca-chat--set-question-prompt-prefix + :not :to-have-been-called)))) + + (it "does nothing when there is no pending question" + (with-temp-buffer + (setq-local eca-chat--pending-question nil) + (spy-on 'eca-chat--refresh-transient-area) + (eca-chat--dismiss-pending-question-for-tool-call "tc-1") + (expect eca-chat--pending-question :to-be nil) + (expect 'eca-chat--refresh-transient-area :not :to-have-been-called)))) + (describe "eca-chat--normalize-question-option" ;; Regression: a `chat/askQuestion' option that is a plain string or a ;; plist without `:label' must not crash rendering with