Skip to content

Commit 07da00c

Browse files
ericdalloeca-agent
andcommitted
Re-generate chat title at 3rd message with full conversation context
The initial title (generated from only the first message) is often too narrow. Now the title is regenerated at the 3rd user prompt using the full conversation history, producing a more accurate summary. - Track user-prompt-count per chat - At prompt 3, pass all messages to the title LLM call - Update title prompt to handle both single-message and full-conversation inputs - Mark manually-renamed titles as custom to suppress automatic re-titling - Add comprehensive tests for title generation lifecycle 🤖 Generated with [eca](https://eca.dev) Co-Authored-By: eca-agent <git@eca.dev>
1 parent 5e79e47 commit 07da00c

File tree

4 files changed

+141
-22
lines changed

4 files changed

+141
-22
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
## Unreleased
44

5+
- Chat titles now re-generate at the 3rd user message using full conversation context for more accurate titles.
6+
57
## 0.125.0
68

79
- Refresh auth token before each LLM API call, preventing stale tokens during long-running tool calls.

resources/prompts/title.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@ You are a title generator. You output ONLY a thread title. Nothing else.
44

55
## Task
66

7-
Convert the user message into a thread title.
7+
Convert the conversation into a thread title.
8+
If given a single message, title that message.
9+
If given a full conversation, title based on the overall direction and outcome.
810
Output: Single line, ≤30 chars, no explanations.
911

1012
## Rules

src/eca/features/chat.clj

Lines changed: 34 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -538,6 +538,7 @@
538538
(messenger/chat-status-changed messenger {:chat-id chat-id :status :running})
539539
(swap! db* assoc-in [:chats chat-id :prompt-id] prompt-id)
540540
(swap! db* assoc-in [:chats chat-id :model] full-model)
541+
(swap! db* update-in [:chats chat-id :user-prompt-count] (fnil inc 0))
541542
(let [chat-ctx (assoc chat-ctx :prompt-id prompt-id)
542543
_ (lifecycle/maybe-renew-auth-token chat-ctx) ;; ensures captured provider-auth fallback is fresh
543544
db @db*
@@ -552,27 +553,37 @@
552553
(swap! db* update-in [:chats chat-id :messages] (fnil conj []) msg))
553554
on-usage-updated (fn [usage]
554555
(when-let [usage (shared/usage-msg->usage usage full-model chat-ctx)]
555-
(lifecycle/send-content! chat-ctx :system (merge {:type :usage} usage))))]
556+
(lifecycle/send-content! chat-ctx :system (merge {:type :usage} usage))))
557+
prompt-count (get-in db [:chats chat-id :user-prompt-count] 0)
558+
retitle? (= prompt-count 3)
559+
generate-title? (and (get-in config [:chat :title])
560+
(not (get-in db [:chats chat-id :title-custom?]))
561+
(or (and (not (get-in db [:chats chat-id :title]))
562+
(not retitle?))
563+
retitle?))]
556564
(assert-compatible-apis-between-models! db chat-id provider model config)
557-
(when (and (not (get-in db [:chats chat-id :title]))
558-
(get-in config [:chat :title]))
559-
(future* config
560-
(when-let [{:keys [output-text]} (llm-api/sync-prompt!
561-
{:provider provider
562-
:model model
563-
:model-capabilities
564-
(assoc model-capabilities :reason? false :tools false :web-search false)
565-
:instructions (f.prompt/chat-title-prompt agent config)
566-
:user-messages user-messages
567-
:config config
568-
:provider-auth provider-auth
569-
:subagent? true})]
570-
(when output-text
571-
(let [title (subs output-text 0 (min (count output-text) 40))]
572-
(swap! db* assoc-in [:chats chat-id :title] title)
573-
(lifecycle/send-content! chat-ctx :system (assoc-some {:type :metadata} :title title))
574-
(when (= :idle (get-in @db* [:chats chat-id :status]))
575-
(db/update-workspaces-cache! @db* metrics)))))))
565+
(when generate-title?
566+
(let [title-messages (if retitle?
567+
(into (get-in db [:chats chat-id :messages] [])
568+
user-messages)
569+
user-messages)]
570+
(future* config
571+
(when-let [{:keys [output-text]} (llm-api/sync-prompt!
572+
{:provider provider
573+
:model model
574+
:model-capabilities
575+
(assoc model-capabilities :reason? false :tools false :web-search false)
576+
:instructions (f.prompt/chat-title-prompt agent config)
577+
:user-messages title-messages
578+
:config config
579+
:provider-auth provider-auth
580+
:subagent? true})]
581+
(when output-text
582+
(let [title (subs output-text 0 (min (count output-text) 40))]
583+
(swap! db* assoc-in [:chats chat-id :title] title)
584+
(lifecycle/send-content! chat-ctx :system (assoc-some {:type :metadata} :title title))
585+
(when (= :idle (get-in @db* [:chats chat-id :status]))
586+
(db/update-workspaces-cache! @db* metrics))))))))
576587
(lifecycle/send-content! chat-ctx :system {:type :progress :state :running :text "Waiting model"})
577588
(if (and (lifecycle/auto-compact? chat-id agent full-model config @db*)
578589
(not (:auto-compacted? chat-ctx)))
@@ -1182,11 +1193,13 @@
11821193

11831194
(defn update-chat
11841195
"Update chat metadata like title.
1185-
Broadcasts the change to all connected clients."
1196+
Broadcasts the change to all connected clients.
1197+
Marks the title as custom to suppress automatic re-titling."
11861198
[{:keys [chat-id title]} db* messenger metrics]
11871199
(when (and (get-in @db* [:chats chat-id])
11881200
title)
11891201
(swap! db* assoc-in [:chats chat-id :title] title)
1202+
(swap! db* assoc-in [:chats chat-id :title-custom?] true)
11901203
(messenger/chat-content-received messenger
11911204
{:chat-id chat-id
11921205
:role "system"

test/eca/features/chat_test.clj

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,108 @@
197197
:role :system}]}
198198
(h/messages)))))))
199199

200+
(defn ^:private prompt-with-title!
201+
"Like prompt! but accepts a :sync-prompt-mock for title generation testing.
202+
Accepts optional :config in mocks to override default config."
203+
[params mocks]
204+
(let [api-mock (fn [{:keys [on-first-response-received on-message-received]}]
205+
(on-first-response-received {:type :text :text "response"})
206+
(on-message-received {:type :text :text "response"})
207+
(on-message-received {:type :finish}))
208+
{:keys [chat-id] :as resp}
209+
(with-redefs [llm-api/sync-or-async-prompt! api-mock
210+
llm-api/sync-prompt! (:sync-prompt-mock mocks)
211+
f.tools/call-tool! (constantly nil)
212+
f.tools/all-tools (constantly [])
213+
f.tools/approval (constantly :allow)
214+
config/await-plugins-resolved! (constantly true)]
215+
(h/config! (merge {:env "test" :chat {:title true}}
216+
(:config mocks)))
217+
(swap! (h/db*) update :models
218+
(fn [models]
219+
(merge {"openai/gpt-5.2" {:tools true}}
220+
(or models {}))))
221+
(f.chat/prompt params (h/db*) (h/messenger) (h/config) (h/metrics)))]
222+
(is (match? {:chat-id string? :status :prompting} resp))
223+
{:chat-id chat-id}))
224+
225+
(deftest title-generation-test
226+
(testing "generates title on first message and re-generates at third with full context"
227+
(h/reset-components!)
228+
(let [sync-prompt-calls* (atom [])
229+
call-count* (atom 0)
230+
sync-mock (fn [params]
231+
(swap! sync-prompt-calls* conj params)
232+
{:output-text (str "Title " (swap! call-count* inc))})]
233+
;; Message 1: should generate title
234+
(let [{:keys [chat-id]} (prompt-with-title! {:message "Help me debug"} {:sync-prompt-mock sync-mock})]
235+
(is (= 1 (count @sync-prompt-calls*))
236+
"Should call sync-prompt! on first message")
237+
(is (= "Title 1" (get-in (h/db) [:chats chat-id :title])))
238+
(is (= 1 (count (:user-messages (first @sync-prompt-calls*))))
239+
"First title uses only the current user message")
240+
241+
;; Message 2: should NOT re-generate title
242+
(h/reset-messenger!)
243+
(prompt-with-title! {:message "Also refactor" :chat-id chat-id} {:sync-prompt-mock sync-mock})
244+
(is (= 1 (count @sync-prompt-calls*))
245+
"Should NOT call sync-prompt! on second message")
246+
(is (= "Title 1" (get-in (h/db) [:chats chat-id :title])))
247+
248+
;; Message 3: should re-generate title with full conversation context
249+
(h/reset-messenger!)
250+
(prompt-with-title! {:message "And add tests" :chat-id chat-id} {:sync-prompt-mock sync-mock})
251+
(is (= 2 (count @sync-prompt-calls*))
252+
"Should call sync-prompt! again on third message")
253+
(is (= "Title 2" (get-in (h/db) [:chats chat-id :title])))
254+
(let [retitle-messages (:user-messages (second @sync-prompt-calls*))]
255+
(is (> (count retitle-messages) 1)
256+
"Third message title should include full conversation history"))
257+
258+
;; Message 4: should NOT re-generate title
259+
(h/reset-messenger!)
260+
(prompt-with-title! {:message "One more thing" :chat-id chat-id} {:sync-prompt-mock sync-mock})
261+
(is (= 2 (count @sync-prompt-calls*))
262+
"Should NOT call sync-prompt! after third message"))))
263+
264+
(testing "manual rename suppresses automatic re-titling"
265+
(h/reset-components!)
266+
(let [sync-prompt-calls* (atom [])
267+
sync-mock (fn [params]
268+
(swap! sync-prompt-calls* conj params)
269+
{:output-text "Auto Title"})]
270+
;; Message 1: generates initial title
271+
(let [{:keys [chat-id]} (prompt-with-title! {:message "Help me debug"} {:sync-prompt-mock sync-mock})]
272+
(is (= 1 (count @sync-prompt-calls*)))
273+
274+
;; Manual rename
275+
(f.chat/update-chat {:chat-id chat-id :title "My Custom Title"} (h/db*) (h/messenger) (h/metrics))
276+
(is (= "My Custom Title" (get-in (h/db) [:chats chat-id :title])))
277+
(is (true? (get-in (h/db) [:chats chat-id :title-custom?])))
278+
279+
;; Messages 2 and 3: should NOT re-generate because of manual rename
280+
(h/reset-messenger!)
281+
(prompt-with-title! {:message "Second msg" :chat-id chat-id} {:sync-prompt-mock sync-mock})
282+
(h/reset-messenger!)
283+
(prompt-with-title! {:message "Third msg" :chat-id chat-id} {:sync-prompt-mock sync-mock})
284+
(is (= 1 (count @sync-prompt-calls*))
285+
"Should NOT re-title after manual rename")
286+
(is (= "My Custom Title" (get-in (h/db) [:chats chat-id :title]))))))
287+
288+
(testing "title disabled in config skips all generation"
289+
(h/reset-components!)
290+
(let [sync-prompt-calls* (atom [])
291+
sync-mock (fn [params]
292+
(swap! sync-prompt-calls* conj params)
293+
{:output-text "Should not appear"})
294+
disabled-mocks {:sync-prompt-mock sync-mock :config {:chat {:title false}}}]
295+
(let [{:keys [chat-id]} (prompt-with-title! {:message "Hello"} disabled-mocks)]
296+
(prompt-with-title! {:message "Second" :chat-id chat-id} disabled-mocks)
297+
(prompt-with-title! {:message "Third" :chat-id chat-id} disabled-mocks)
298+
(is (zero? (count @sync-prompt-calls*))
299+
"Should never call sync-prompt! when title is disabled")
300+
(is (nil? (get-in (h/db) [:chats chat-id :title])))))))
301+
200302
(deftest context-overflow-auto-compact-guard-test
201303
(testing "context overflow after auto-compact reports error instead of looping"
202304
(h/reset-components!)

0 commit comments

Comments
 (0)