Files
pulse/internal/ai/chat/session_title_test.go
T
rcourtman 70a20f6ac7 Generate session titles from the first exchange (OpenCode parity)
Session titles were the truncated first prompt, so the picker filled
with 'Reply with exactly: OK' and five identical 'hows esphome' rows.
OpenCode background-generates a short title after the first exchange;
Pulse now does the equivalent: once the done event has reached the
client, the chat runtime asks the configured chat model for a <=50-char
title from the first user message and reply, then renames the session.

- Runs only on the first exchange; any customized title is left alone,
  with a re-check before persisting to lose races against a concurrent
  rename. Failure or timeout keeps the placeholder.
- Title requests pass the model-boundary sanitizer like normal turns
  and record usage as assistant_session_title.
- Synchronous after done (no user-visible latency, no goroutine racing
  service teardown), bounded at 20s for slow reasoning routes.

Verified by tests end-to-end (hook through Rename); live verification
on a real provider route pending while the dev backend sits in mock
assistant mode, which intercepts turns before the agentic path.
2026-06-11 16:21:58 +01:00

96 lines
3.8 KiB
Go

package chat
import (
"context"
"testing"
"github.com/rcourtman/pulse-go-rewrite/internal/ai/providers"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type titleStubProvider struct {
title string
calls int
}
func (p *titleStubProvider) Chat(ctx context.Context, req providers.ChatRequest) (*providers.ChatResponse, error) {
p.calls++
return &providers.ChatResponse{Content: p.title, Model: req.Model, InputTokens: 10, OutputTokens: 5}, nil
}
func (p *titleStubProvider) TestConnection(ctx context.Context) error { return nil }
func (p *titleStubProvider) Name() string { return "stub" }
func (p *titleStubProvider) ListModels(ctx context.Context) ([]providers.ModelInfo, error) {
return nil, nil
}
func (p *titleStubProvider) ChatStream(ctx context.Context, req providers.ChatRequest, callback providers.StreamCallback) error {
return nil
}
func (p *titleStubProvider) SupportsThinking(model string) bool { return false }
func newTitleTestService(t *testing.T, provider providers.StreamingProvider) (*Service, *SessionStore) {
t.Helper()
store, err := NewSessionStore(t.TempDir())
require.NoError(t, err)
return &Service{sessions: store, provider: provider, started: true}, store
}
func seedFirstExchange(t *testing.T, store *SessionStore, prompt, answer string) *Session {
t.Helper()
session, err := store.Create()
require.NoError(t, err)
require.NoError(t, store.AddMessage(session.ID, Message{Role: "user", Content: prompt}))
require.NoError(t, store.AddMessage(session.ID, Message{Role: "assistant", Content: answer}))
return session
}
func TestGenerateSessionTitleUpgradesPlaceholderAfterFirstExchange(t *testing.T) {
provider := &titleStubProvider{title: "\"Frigate memory check on delly.\"\nExtra line ignored"}
svc, store := newTitleTestService(t, provider)
session := seedFirstExchange(t, store, "how much memory is frigate using on delly right now?", "Frigate uses 1.2 GiB.")
require.NoError(t, svc.generateSessionTitle(context.Background(), session.ID))
updated, err := store.Get(session.ID)
require.NoError(t, err)
assert.Equal(t, "Frigate memory check on delly", updated.Title)
assert.Equal(t, 1, provider.calls)
}
func TestGenerateSessionTitleSkipsUserRenamedSessions(t *testing.T) {
provider := &titleStubProvider{title: "Generated title"}
svc, store := newTitleTestService(t, provider)
session := seedFirstExchange(t, store, "check pbs backups", "All PBS jobs succeeded.")
_, err := store.Rename(session.ID, "My backup investigation")
require.NoError(t, err)
err = svc.generateSessionTitle(context.Background(), session.ID)
require.Error(t, err)
assert.Equal(t, 0, provider.calls)
updated, err := store.Get(session.ID)
require.NoError(t, err)
assert.Equal(t, "My backup investigation", updated.Title)
}
func TestGenerateSessionTitleSkipsLaterTurns(t *testing.T) {
provider := &titleStubProvider{title: "Generated title"}
svc, store := newTitleTestService(t, provider)
session := seedFirstExchange(t, store, "first prompt", "first answer")
require.NoError(t, store.AddMessage(session.ID, Message{Role: "user", Content: "second prompt"}))
require.NoError(t, store.AddMessage(session.ID, Message{Role: "assistant", Content: "second answer"}))
err := svc.generateSessionTitle(context.Background(), session.ID)
require.Error(t, err)
assert.Equal(t, 0, provider.calls)
}
func TestNormalizeGeneratedSessionTitle(t *testing.T) {
assert.Equal(t, "Disk pressure on minipc", normalizeGeneratedSessionTitle(" \"Disk pressure on minipc.\" "))
assert.Equal(t, "", normalizeGeneratedSessionTitle(" \n"))
long := normalizeGeneratedSessionTitle("An extremely verbose session title that keeps going well past the fifty character budget")
assert.LessOrEqual(t, len([]rune(long)), 53, "should truncate near the 50-char budget (plus ellipsis)")
assert.Contains(t, long, "...")
}