Skip to content

Commit 909c5f7

Browse files
committed
Docs n tests for editable session titles
Signed-off-by: krissetto <[email protected]>
1 parent 67ed3cb commit 909c5f7

File tree

13 files changed

+594
-6
lines changed

13 files changed

+594
-6
lines changed

docs/USAGE.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,7 @@ During TUI sessions, you can use special slash commands. Type `/` to see all ava
172172
| `/star` | Toggle star on current session |
173173
| `/theme` | Change the color theme (see [Theming](#theming)) |
174174
| `/think` | Toggle thinking/reasoning mode |
175+
| `/title` | Set or regenerate session title (usage: /title [new title]) |
175176
| `/yolo` | Toggle automatic approval of tool calls |
176177

177178
#### Runtime Model Switching
@@ -279,6 +280,30 @@ Themes can customize colors in three sections: `colors`, `chroma` (syntax highli
279280

280281
See the [built-in themes on GitHub](https://github.com/docker/cagent/tree/main/pkg/tui/styles/themes) for complete examples.
281282

283+
#### Session Title Editing
284+
285+
You can customize session titles to make them more meaningful and easier to find later. By default, cagent automatically generates titles based on your first message, but you can override or regenerate them at any time.
286+
287+
**Using the `/title` command:**
288+
289+
```
290+
/title # Regenerate title using AI (based on at most the last 2 user messages)
291+
/title My Custom Title # Set a specific title
292+
```
293+
294+
**Using the sidebar:**
295+
296+
In the TUI, you can click on the pencil icon (✎) next to the session title in the sidebar to edit it inline:
297+
298+
1. Click the pencil icon next to the title
299+
2. Type your new title
300+
3. Press Enter to save, or Escape to cancel
301+
302+
**Notes:**
303+
- Manually set titles are preserved and won't be overwritten by auto-generation
304+
- Title changes are persisted immediately to the session
305+
- Works with both local and remote runtimes
306+
282307
## 🔧 Configuration Reference
283308
284309
### Agent Properties

e2e/runtime_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ func TestRuntime_OpenAI_Basic(t *testing.T) {
3232

3333
response := sess.GetLastAssistantMessageContent()
3434
assert.Equal(t, "2 + 2 equals 4.", response)
35-
assert.Equal(t, "Simple Math: Addition of 2 and 2", sess.Title)
35+
// Title generation is now handled by pkg/app or pkg/server, not the runtime
3636
}
3737

3838
func TestRuntime_Mistral_Basic(t *testing.T) {
@@ -55,5 +55,5 @@ func TestRuntime_Mistral_Basic(t *testing.T) {
5555

5656
response := sess.GetLastAssistantMessageContent()
5757
assert.Equal(t, "The sum of 2 + 2 is 4.", response)
58-
assert.Equal(t, "Math Basics: Simple Addition", sess.Title)
58+
// Title generation is now handled by pkg/app or pkg/server, not the runtime
5959
}

e2e/testdata/cassettes/TestRuntime_Mistral_Basic.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ interactions:
5858
proto_minor: 1
5959
content_length: 0
6060
host: api.mistral.ai
61-
body: '{"messages":[{"content":"You are a helpful AI assistant that generates concise, descriptive titles for conversations. You will be given a conversation history and asked to create a single-line title that captures the main topic. Never use newlines or line breaks in your response.","role":"system"},{"content":"Based on the following message a user sent to an AI assistant, generate a short, descriptive title (maximum 50 characters) that captures the main topic or purpose of the conversation. Return ONLY the title text on a single line, nothing else. Do not include any newlines, explanations, or formatting.\n\nUser message: What''s 2+2?\n\n","role":"user"}],"model":"mistral-small","stream_options":{"include_usage":true},"stream":true}'
61+
body: '{"messages":[{"content":"You are a helpful AI assistant that generates concise, descriptive titles for conversations. You will be given up to 2 recent user messages and asked to create a single-line title that captures the main topic. Never use newlines or line breaks in your response.","role":"system"},{"content":"Based on the following recent user messages from a conversation with an AI assistant, generate a short, descriptive title (maximum 50 characters) that captures the main topic or purpose of the conversation. Return ONLY the title text on a single line, nothing else. Do not include any newlines, explanations, or formatting.\n\nRecent user messages:\n1. What''s 2+2?\n\n\n","role":"user"}],"model":"mistral-small","stream_options":{"include_usage":true},"stream":true}'
6262
url: https://api.mistral.ai/v1/chat/completions
6363
method: POST
6464
response:

e2e/testdata/cassettes/TestRuntime_OpenAI_Basic.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ interactions:
5252
proto_minor: 1
5353
content_length: 0
5454
host: api.openai.com
55-
body: '{"messages":[{"content":"You are a helpful AI assistant that generates concise, descriptive titles for conversations. You will be given a conversation history and asked to create a single-line title that captures the main topic. Never use newlines or line breaks in your response.","role":"system"},{"content":"Based on the following message a user sent to an AI assistant, generate a short, descriptive title (maximum 50 characters) that captures the main topic or purpose of the conversation. Return ONLY the title text on a single line, nothing else. Do not include any newlines, explanations, or formatting.\n\nUser message: What''s 2+2?\n\n","role":"user"}],"model":"gpt-3.5-turbo","stream_options":{"include_usage":true},"stream":true}'
55+
body: '{"messages":[{"content":"You are a helpful AI assistant that generates concise, descriptive titles for conversations. You will be given up to 2 recent user messages and asked to create a single-line title that captures the main topic. Never use newlines or line breaks in your response.","role":"system"},{"content":"Based on the following recent user messages from a conversation with an AI assistant, generate a short, descriptive title (maximum 50 characters) that captures the main topic or purpose of the conversation. Return ONLY the title text on a single line, nothing else. Do not include any newlines, explanations, or formatting.\n\nRecent user messages:\n1. What''s 2+2?\n\n\n","role":"user"}],"model":"gpt-3.5-turbo","stream_options":{"include_usage":true},"stream":true}'
5656
url: https://api.openai.com/v1/chat/completions
5757
method: POST
5858
response:

pkg/app/app_test.go

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"testing"
66

7+
tea "charm.land/bubbletea/v2"
78
"github.com/stretchr/testify/assert"
89
"github.com/stretchr/testify/require"
910

@@ -140,3 +141,130 @@ func TestApp_NewSession_WithNilSession(t *testing.T) {
140141
// Default values
141142
assert.False(t, app.Session().Thinking, "NewSession with nil should use default thinking=true")
142143
}
144+
145+
func TestApp_UpdateSessionTitle(t *testing.T) {
146+
t.Parallel()
147+
148+
ctx := t.Context()
149+
150+
t.Run("updates title in session", func(t *testing.T) {
151+
t.Parallel()
152+
153+
rt := &mockRuntime{}
154+
sess := session.New()
155+
events := make(chan tea.Msg, 16)
156+
app := &App{
157+
runtime: rt,
158+
session: sess,
159+
events: events,
160+
}
161+
162+
err := app.UpdateSessionTitle(ctx, "New Title")
163+
require.NoError(t, err)
164+
165+
assert.Equal(t, "New Title", sess.Title)
166+
167+
// Check that an event was emitted
168+
select {
169+
case event := <-events:
170+
titleEvent, ok := event.(*runtime.SessionTitleEvent)
171+
require.True(t, ok, "should emit SessionTitleEvent")
172+
assert.Equal(t, "New Title", titleEvent.Title)
173+
default:
174+
t.Fatal("expected SessionTitleEvent to be emitted")
175+
}
176+
})
177+
178+
t.Run("returns error when no session", func(t *testing.T) {
179+
t.Parallel()
180+
181+
rt := &mockRuntime{}
182+
app := &App{
183+
runtime: rt,
184+
session: nil,
185+
}
186+
187+
err := app.UpdateSessionTitle(ctx, "New Title")
188+
require.Error(t, err)
189+
assert.Contains(t, err.Error(), "no active session")
190+
})
191+
192+
t.Run("returns ErrTitleGenerating when generation in progress", func(t *testing.T) {
193+
t.Parallel()
194+
195+
rt := &mockRuntime{}
196+
sess := session.New()
197+
events := make(chan tea.Msg, 16)
198+
app := &App{
199+
runtime: rt,
200+
session: sess,
201+
events: events,
202+
}
203+
204+
// Simulate title generation in progress
205+
app.titleGenerating.Store(true)
206+
207+
err := app.UpdateSessionTitle(ctx, "New Title")
208+
require.ErrorIs(t, err, ErrTitleGenerating)
209+
210+
// Title should not be updated
211+
assert.Empty(t, sess.Title)
212+
})
213+
}
214+
215+
func TestApp_RegenerateSessionTitle(t *testing.T) {
216+
t.Parallel()
217+
218+
ctx := t.Context()
219+
220+
t.Run("returns error when no session", func(t *testing.T) {
221+
t.Parallel()
222+
223+
rt := &mockRuntime{}
224+
app := &App{
225+
runtime: rt,
226+
session: nil,
227+
}
228+
229+
err := app.RegenerateSessionTitle(ctx)
230+
require.Error(t, err)
231+
assert.Contains(t, err.Error(), "no active session")
232+
})
233+
234+
t.Run("returns error when no title generator is available", func(t *testing.T) {
235+
t.Parallel()
236+
237+
rt := &mockRuntime{}
238+
sess := session.New()
239+
events := make(chan tea.Msg, 16)
240+
app := &App{
241+
runtime: rt,
242+
session: sess,
243+
events: events,
244+
// titleGen is nil - no title generator available
245+
}
246+
247+
err := app.RegenerateSessionTitle(ctx)
248+
require.Error(t, err)
249+
assert.Contains(t, err.Error(), "title regeneration not available")
250+
})
251+
252+
t.Run("returns ErrTitleGenerating when already generating", func(t *testing.T) {
253+
t.Parallel()
254+
255+
rt := &mockRuntime{}
256+
sess := session.New()
257+
events := make(chan tea.Msg, 16)
258+
app := &App{
259+
runtime: rt,
260+
session: sess,
261+
events: events,
262+
}
263+
264+
// Simulate title generation already in progress
265+
app.titleGenerating.Store(true)
266+
267+
err := app.RegenerateSessionTitle(ctx)
268+
require.ErrorIs(t, err, ErrTitleGenerating)
269+
})
270+
}

pkg/runtime/commands_test.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,9 @@ func (m *mockRuntime) SessionStore() session.Store { return nil }
4949
func (m *mockRuntime) Summarize(context.Context, *session.Session, string, chan Event) {
5050
}
5151

52+
func (m *mockRuntime) RegenerateTitle(context.Context, *session.Session, chan Event) {
53+
}
54+
5255
func TestResolveCommand_SimpleCommand(t *testing.T) {
5356
t.Parallel()
5457

pkg/server/server_test.go

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,61 @@ func unmarshal(t *testing.T, buf []byte, v any) {
169169
require.NoError(t, err)
170170
}
171171

172+
func TestServer_UpdateSessionTitle(t *testing.T) {
173+
t.Parallel()
174+
175+
ctx := t.Context()
176+
store := session.NewInMemorySessionStore()
177+
lnPath := startServerWithStore(t, ctx, prepareAgentsDir(t), store)
178+
179+
// Create a session first
180+
createResp := httpDo(t, ctx, http.MethodPost, lnPath, "/api/sessions", map[string]any{})
181+
var createdSession session.Session
182+
unmarshal(t, createResp, &createdSession)
183+
require.NotEmpty(t, createdSession.ID)
184+
185+
// Update the session title
186+
newTitle := "My Custom Title"
187+
updateResp := httpDo(t, ctx, http.MethodPatch, lnPath, "/api/sessions/"+createdSession.ID+"/title", api.UpdateSessionTitleRequest{Title: newTitle})
188+
var titleResp api.UpdateSessionTitleResponse
189+
unmarshal(t, updateResp, &titleResp)
190+
191+
assert.Equal(t, createdSession.ID, titleResp.ID)
192+
assert.Equal(t, newTitle, titleResp.Title)
193+
194+
// Verify the session was updated in the store
195+
getResp := httpGET(t, ctx, lnPath, "/api/sessions/"+createdSession.ID)
196+
var sessionResp api.SessionResponse
197+
unmarshal(t, getResp, &sessionResp)
198+
199+
assert.Equal(t, newTitle, sessionResp.Title)
200+
}
201+
202+
func startServerWithStore(t *testing.T, ctx context.Context, agentsDir string, store session.Store) string {
203+
t.Helper()
204+
205+
runConfig := config.RuntimeConfig{}
206+
207+
sources, err := config.ResolveSources(agentsDir)
208+
require.NoError(t, err)
209+
srv, err := New(ctx, store, &runConfig, 0, sources)
210+
require.NoError(t, err)
211+
212+
socketPath := "unix://" + filepath.Join(t.TempDir(), "sock")
213+
ln, err := Listen(ctx, socketPath)
214+
require.NoError(t, err)
215+
go func() {
216+
<-ctx.Done()
217+
_ = ln.Close()
218+
}()
219+
220+
go func() {
221+
_ = srv.Serve(ctx, ln)
222+
}()
223+
224+
return socketPath
225+
}
226+
172227
type mockStore struct {
173228
session.Store
174229
}

pkg/session/session_test.go

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -318,3 +318,79 @@ func TestUpdateLastAssistantMessageUsage_UpdatesOnlyLast(t *testing.T) {
318318
assert.InEpsilon(t, 0.02, messages[2].Message.Cost, 0.0001)
319319
assert.Equal(t, "new-model", messages[2].Message.Model)
320320
}
321+
322+
func TestGetLastUserMessages(t *testing.T) {
323+
t.Parallel()
324+
325+
testAgent := &agent.Agent{}
326+
327+
t.Run("empty session returns empty slice", func(t *testing.T) {
328+
t.Parallel()
329+
s := New()
330+
assert.Empty(t, s.GetLastUserMessages(2))
331+
})
332+
333+
t.Run("session with fewer messages than requested returns all", func(t *testing.T) {
334+
t.Parallel()
335+
s := New()
336+
s.AddMessage(NewAgentMessage(testAgent, &chat.Message{
337+
Role: chat.MessageRoleUser,
338+
Content: "Only message",
339+
}))
340+
msgs := s.GetLastUserMessages(2)
341+
assert.Len(t, msgs, 1)
342+
assert.Equal(t, "Only message", msgs[0])
343+
})
344+
345+
t.Run("session returns last n user messages in order", func(t *testing.T) {
346+
t.Parallel()
347+
s := New()
348+
s.AddMessage(NewAgentMessage(testAgent, &chat.Message{
349+
Role: chat.MessageRoleUser,
350+
Content: "First",
351+
}))
352+
s.AddMessage(NewAgentMessage(testAgent, &chat.Message{
353+
Role: chat.MessageRoleAssistant,
354+
Content: "Response 1",
355+
}))
356+
s.AddMessage(NewAgentMessage(testAgent, &chat.Message{
357+
Role: chat.MessageRoleUser,
358+
Content: "Second",
359+
}))
360+
s.AddMessage(NewAgentMessage(testAgent, &chat.Message{
361+
Role: chat.MessageRoleAssistant,
362+
Content: "Response 2",
363+
}))
364+
s.AddMessage(NewAgentMessage(testAgent, &chat.Message{
365+
Role: chat.MessageRoleUser,
366+
Content: "Third",
367+
}))
368+
369+
msgs := s.GetLastUserMessages(2)
370+
assert.Len(t, msgs, 2)
371+
assert.Equal(t, "Second", msgs[0]) // Ordered oldest to newest
372+
assert.Equal(t, "Third", msgs[1])
373+
})
374+
375+
t.Run("skips empty user messages", func(t *testing.T) {
376+
t.Parallel()
377+
s := New()
378+
s.AddMessage(NewAgentMessage(testAgent, &chat.Message{
379+
Role: chat.MessageRoleUser,
380+
Content: "First",
381+
}))
382+
s.AddMessage(NewAgentMessage(testAgent, &chat.Message{
383+
Role: chat.MessageRoleUser,
384+
Content: " ", // Empty after trim
385+
}))
386+
s.AddMessage(NewAgentMessage(testAgent, &chat.Message{
387+
Role: chat.MessageRoleUser,
388+
Content: "Third",
389+
}))
390+
391+
msgs := s.GetLastUserMessages(2)
392+
assert.Len(t, msgs, 2)
393+
assert.Equal(t, "First", msgs[0])
394+
assert.Equal(t, "Third", msgs[1])
395+
})
396+
}

pkg/sessiontitle/generator.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,8 @@ import (
1717
)
1818

1919
const (
20-
systemPrompt = "You are a helpful AI assistant that generates concise, descriptive titles for conversations. You will be given recent user messages and asked to create a title that captures the main topic."
21-
userPromptFormat = "Based on the following recent user messages from a conversation with an AI assistant, generate a short, descriptive title (maximum 50 characters) that captures the main topic or purpose of the conversation. Return ONLY the title text, nothing else.\n\nRecent user messages:\n%s\n\n"
20+
systemPrompt = "You are a helpful AI assistant that generates concise, descriptive titles for conversations. You will be given up to 2 recent user messages and asked to create a single-line title that captures the main topic. Never use newlines or line breaks in your response."
21+
userPromptFormat = "Based on the following recent user messages from a conversation with an AI assistant, generate a short, descriptive title (maximum 50 characters) that captures the main topic or purpose of the conversation. Return ONLY the title text on a single line, nothing else. Do not include any newlines, explanations, or formatting.\n\nRecent user messages:\n%s\n\n"
2222
)
2323

2424
// Generator generates session titles using a one-shot LLM completion.

0 commit comments

Comments
 (0)