From 9a67b1a4648e4f8c1da82952e976a9e4e55ec465 Mon Sep 17 00:00:00 2001 From: Juha Itkonen Date: Fri, 19 Jun 2026 11:22:15 +0300 Subject: [PATCH] Keep postRequest primary-chat only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Route subagent completions exclusively through subagentPostRequest so post-request hooks do not emit duplicate notifications for subagent turns. Update the hook docs, changelog, and lifecycle tests to match the contract. 🤖 Generated with [ECA](https://eca.dev) (openai/gpt-5.5 - high) Co-Authored-By: eca-agent --- CHANGELOG.md | 1 + docs/config/hooks.md | 4 ++-- src/eca/features/chat/lifecycle.clj | 24 ++++++++++-------------- test/eca/features/hooks_test.clj | 19 +++++++++---------- 4 files changed, 22 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d3adc2e47..7a2bcccfc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- Bugfix: `postRequest` hook no longer fires for subagent chats; subagents use `subagentPostRequest` only, preventing duplicate hook notifications. (#505) - Bugfix: clearer rejected tool-call result wording so models no longer assume a rejected edit was applied; it now states the call did not run and changed nothing. (#507) - Bugfix: `ask_user` normalizes the `options` argument so a malformed value (e.g. a stringified options an LLM sometimes emits) no longer reaches clients as broken choices. Accepts string/object arrays, recovers a JSON-encoded string, and drops unusable input. diff --git a/docs/config/hooks.md b/docs/config/hooks.md index f9a89773b..2983b803c 100644 --- a/docs/config/hooks.md +++ b/docs/config/hooks.md @@ -267,7 +267,7 @@ Fires before a prompt is sent to the LLM. Use for prompt validation, rewriting, ### `postRequest` -Fires after a primary-agent prompt finishes. Also runs for subagents. Primary use: validate the response or trigger a follow-up turn. +Fires after a primary-agent prompt finishes. It does not run for subagent chats; use [`subagentPostRequest`](#subagentpostrequest) for subagent completions. Primary use: validate the response or trigger a follow-up turn. !!! note `postRequest` fires only after LLM responses. Display-only commands (`/hooks`, `/model`, `/costs`) and compaction prompts (`/compact` and auto-compact) do **not** trigger it — use [`postCompact`](#postcompact) for compaction. @@ -286,7 +286,7 @@ Fires after a primary-agent prompt finishes. Also runs for subagents. Primary us ### `subagentPostRequest` -Fires after a subagent prompt finishes, **in addition** to `postRequest` (which also runs for subagents). Use for subagent-specific follow-ups or notifications. +Fires after a subagent prompt finishes. Use for subagent-specific follow-ups or notifications; this is the subagent counterpart to primary-chat `postRequest`. - **Input adds** — `response`, `follow_up_active`, and `parent_chat_id`. - **Honored output** — same as [`postRequest`](#postrequest): `followUp`, `systemMessage`, `suppressOutput`, `continue: false` + `stopReason`. diff --git a/src/eca/features/chat/lifecycle.clj b/src/eca/features/chat/lifecycle.clj index 8437f6102..d20fbb099 100644 --- a/src/eca/features/chat/lifecycle.clj +++ b/src/eca/features/chat/lifecycle.clj @@ -306,15 +306,15 @@ (run-post-compact-hooks! chat-ctx trigger summary))) (defn ^:private run-post-request-hooks! - "Run postRequest (and subagentPostRequest for subagents) hooks. + "Run postRequest (for primary chats) and subagentPostRequest (for subagents) hooks. Returns {:follow-up-text string-or-nil :stop-turn? boolean :stop-reason string-or-nil :stop-hook-name string-or-nil}. - postRequest exit 2 with stderr is treated as followUp: stderr becomes the - followUp text, analogous to how preToolCall/postToolCall exit 2 makes stderr - LLM-visible payload. This is because postRequest runs after the prompt - finished, so exit 2 cannot 'block' the request; instead it contributes a - continuation instruction." + postRequest/subagentPostRequest exit 2 with stderr is treated as followUp: + stderr becomes the followUp text, analogous to how preToolCall/postToolCall + exit 2 makes stderr LLM-visible payload. This is because these hooks run + after the prompt finished, so exit 2 cannot 'block' the request; instead it + contributes a continuation instruction." [{:keys [db* config chat-id response] :as chat-ctx}] (let [db @db* results* (atom []) @@ -329,18 +329,14 @@ :on-after-action (fn [result] (notify-after-hook-action! chat-ctx result) (swap! results* conj result))} - _ (f.hooks/trigger-if-matches! :postRequest base-hook-data cb db config) - ;; A successful continue:false on a postRequest hook stops the turn, so - ;; the remaining relevant hooks must not run. For subagents this means - ;; subagentPostRequest is skipped, otherwise it could emit side effects - ;; (systemMessage, followUp) after the turn was already stopped. - post-request-stopped? (boolean (some f.hooks/successful-continue-false? @results*)) - _ (when (and subagent? (not post-request-stopped?)) + ;; postRequest is primary-only. Subagent chats use subagentPostRequest. + _ (if subagent? (f.hooks/trigger-if-matches! :subagentPostRequest (assoc base-hook-data :parent-chat-id (db/parent-chat-id db chat-id)) cb db - config)) + config) + (f.hooks/trigger-if-matches! :postRequest base-hook-data cb db config)) hook-results @results* follow-ups (->> hook-results (keep (fn [{:keys [parsed exit raw-error]}] diff --git a/test/eca/features/hooks_test.clj b/test/eca/features/hooks_test.clj index c7668bcdf..1b4ec0001 100644 --- a/test/eca/features/hooks_test.clj +++ b/test/eca/features/hooks_test.clj @@ -1854,8 +1854,8 @@ (h/db) false "openai/gpt")) (is (= ["subagentStart"] @fired-types*))))) -(deftest subagent-finish-fires-both-post-request-hooks-test - (testing "subagent finish fires postRequest then subagentPostRequest" +(deftest subagent-finish-fires-subagentpostrequest-only-test + (testing "subagent finish fires only subagentPostRequest, not postRequest" (h/reset-components!) (swap! (h/db*) assoc :chats {"sub-1" {:agent "explorer" :subagent {:max-steps 10} @@ -1875,10 +1875,10 @@ :agent "explorer" :messenger (h/messenger) :metrics (h/metrics)})) - (is (= ["postRequest" "subagentPostRequest"] @fired-types*))))) + (is (= ["subagentPostRequest"] @fired-types*))))) -(deftest subagent-postrequest-skipped-when-postrequest-stops-test - (testing "a successful postRequest continue:false stops the turn and skips subagentPostRequest" +(deftest subagent-subagentpostrequest-continue-false-stops-turn-test + (testing "subagentPostRequest continue:false stops the subagent turn" (h/reset-components!) (swap! (h/db*) assoc :chats {"sub-1" {:agent "explorer" :subagent {:max-steps 10} @@ -1891,7 +1891,6 @@ (with-redefs [f.hooks/run-shell-cmd (fn [{:keys [input]}] (let [data (json/parse-string input true)] (swap! fired-types* conj (:hook_type data))) - ;; postRequest returns a successful continue:false {:exit 0 :out "{\"continue\":false,\"stopReason\":\"halt\"}" :err nil})] @@ -1902,8 +1901,8 @@ :agent "explorer" :messenger (h/messenger) :metrics (h/metrics)})) - ;; Only postRequest ran; subagentPostRequest was skipped. - (is (= ["postRequest"] @fired-types*)) + ;; Only subagentPostRequest ran; postRequest is not dispatched for subagents. + (is (= ["subagentPostRequest"] @fired-types*)) ;; The turn-stop message is still surfaced on the subagent chat. The binding ;; (chat-id/parent-chat-id) is the contract here; the exact wording is covered ;; by lifecycle/turn-stopped-by-hook-message-test. @@ -2011,13 +2010,13 @@ (is (false? @finished*))))) (deftest subagent-postrequest-stop-binds-to-subagent-chat-test - (testing "subagent postRequest continue:false surfaces the prefixed stop message bound to the subagent chat" + (testing "subagentPostRequest continue:false surfaces the prefixed stop message bound to the subagent chat" (h/reset-components!) ;; Mark the chat as a subagent so finish runs the post-request hooks for it; ;; the chat-ctx carries :parent-chat-id, so send-content! tags the message ;; with it (nested under the parent in the UI). (swap! (h/db*) assoc-in [:chats "sub-1" :subagent] {:max-steps nil}) - (h/config! {:hooks {"stopper" {:type "postRequest" + (h/config! {:hooks {"stopper" {:type "subagentPostRequest" :visible false :actions [{:type "shell" :shell "echo"}]}}}) (with-redefs [f.hooks/run-shell-cmd (constantly {:exit 0