diff --git a/CHANGELOG.md b/CHANGELOG.md index f679bbc..e978851 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. - Paginate long chats instead of replaying the full history on open. With `eca-chat-history-page-size` non-nil (default 50), `eca-chat-resume` opens a chat with only the newest page and shows a clickable "Load older messages" control at the top to page through earlier history on demand via the new `chat/history` request (also bound to `C-c C-S-o`). Older pages are prepended above the existing content, reusing the streaming renderer (subagent nesting included) and kept read-only like the rest of the history. Set `eca-chat-history-page-size` to nil to keep the previous full-replay behavior. - Bugfix: resuming a previous session after a server restart no longer reopens it as a `*Closed session*` (mode-line) / `This chat is closed` buffer that rejects new prompts. `eca-chat-exit` (run on restart) marks the most-recent chat buffer `eca-chat--closed` but leaves it in the session registry; `eca-chat-opened` was treating that live-but-closed buffer as "already known" and replaying the resumed history into it. It now treats a registered buffer marked `eca-chat--closed` as stale, killing the leftover closed buffer and creating a fresh, writable one in its place. - Color the Doom Emacs workspace tabline by ECA session status: orange when a chat waits for approval/question, dim yellow while running. Enabled automatically on Doom (`:ui workspaces` module), disable with `eca-doom-workspace-tabs`. diff --git a/eca-chat.el b/eca-chat.el index 8801b30..1faeb08 100644 --- a/eca-chat.el +++ b/eca-chat.el @@ -3264,6 +3264,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))) @@ -3303,6 +3306,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))) @@ -3884,6 +3890,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 1e8e041..a966d98 100644 --- a/test/eca-chat-test.el +++ b/test/eca-chat-test.el @@ -374,6 +374,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