Files
viettranx cd4a8bd10f fix(store): persist last_prompt_tokens via sessions.metadata for accurate UI display
- Store last_prompt_tokens in sessions.metadata JSONB (PostgreSQL + SQLite)
- Update SessionsList queries to retrieve metadata and provide token display values
- Add fallback heuristic for sessions without metadata (estimated from history)
- Add tests: sessions_list_heuristic_test.go, sessions_list_metadata_tokens_test.go
- Add integration test: sessions_display_tokens_integration_test.go
2026-04-23 08:31:53 +07:00

250 lines
7.4 KiB
Go

//go:build sqlite || sqliteonly
package sqlitestore
import (
"context"
"encoding/json"
"fmt"
"strings"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/providers"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// buildSessionFilter builds a dynamic WHERE clause from SessionListOpts using ? placeholders.
func buildSessionFilter(opts store.SessionListOpts, tableAlias string) (string, []any) {
prefix := ""
if tableAlias != "" {
prefix = tableAlias + "."
}
var conditions []string
var args []any
if opts.AgentID != "" {
conditions = append(conditions, prefix+"session_key LIKE ?")
args = append(args, "agent:"+opts.AgentID+":%")
}
if opts.Channel != "" {
conditions = append(conditions, prefix+"session_key LIKE ?")
args = append(args, "agent:%:"+opts.Channel+":%")
}
if opts.UserID != "" {
conditions = append(conditions, prefix+"user_id = ?")
args = append(args, opts.UserID)
}
if opts.TenantID != uuid.Nil {
conditions = append(conditions, prefix+"tenant_id = ?")
args = append(args, opts.TenantID)
}
if len(conditions) == 0 {
return "", nil
}
return " WHERE " + strings.Join(conditions, " AND "), args
}
func (s *SQLiteSessionStore) List(ctx context.Context, agentID string) []store.SessionInfo {
var conditions []string
var args []any
if agentID != "" {
conditions = append(conditions, "session_key LIKE ?")
args = append(args, "agent:"+agentID+":%")
}
if !store.IsCrossTenant(ctx) {
tid := store.TenantIDFromContext(ctx)
if tid != uuid.Nil {
conditions = append(conditions, "tenant_id = ?")
args = append(args, tid)
}
}
where := ""
if len(conditions) > 0 {
where = " WHERE " + strings.Join(conditions, " AND ")
}
rows, err := s.db.QueryContext(ctx,
"SELECT session_key, messages, created_at, updated_at, label, channel, user_id, COALESCE(metadata, '{}') FROM sessions"+where+" ORDER BY updated_at DESC",
args...)
if err != nil {
return nil
}
defer rows.Close()
var result []store.SessionInfo
for rows.Next() {
var key string
var msgsJSON []byte
stCreated, stUpdated := scanTimePair()
var label, channel, userID *string
var metaJSON []byte
if err := rows.Scan(&key, &msgsJSON, stCreated, stUpdated, &label, &channel, &userID, &metaJSON); err != nil {
continue
}
var msgs []providers.Message
json.Unmarshal(msgsJSON, &msgs)
var meta map[string]string
if len(metaJSON) > 0 {
json.Unmarshal(metaJSON, &meta)
}
result = append(result, store.SessionInfo{
Key: key,
MessageCount: len(msgs),
Created: stCreated.Time,
Updated: stUpdated.Time,
Label: derefStr(label),
Channel: derefStr(channel),
UserID: derefStr(userID),
Metadata: meta,
})
}
return result
}
func (s *SQLiteSessionStore) ListPaged(ctx context.Context, opts store.SessionListOpts) store.SessionListResult {
limit := opts.Limit
if limit <= 0 {
limit = 20
}
offset := max(opts.Offset, 0)
where, whereArgs := buildSessionFilter(opts, "")
var total int
countQ := "SELECT COUNT(*) FROM sessions" + where
if err := s.db.QueryRowContext(ctx, countQ, whereArgs...).Scan(&total); err != nil {
return store.SessionListResult{Sessions: []store.SessionInfo{}, Total: 0}
}
// Use json_array_length (SQLite built-in) instead of jsonb_array_length.
selectQ := fmt.Sprintf(`SELECT session_key, json_array_length(messages), created_at, updated_at, label, channel, user_id, COALESCE(metadata, '{}')
FROM sessions%s ORDER BY updated_at DESC LIMIT ? OFFSET ?`, where)
selectArgs := append(append([]any{}, whereArgs...), limit, offset)
rows, err := s.db.QueryContext(ctx, selectQ, selectArgs...)
if err != nil {
return store.SessionListResult{Sessions: []store.SessionInfo{}, Total: total}
}
defer rows.Close()
var result []store.SessionInfo
for rows.Next() {
var key string
var msgCount int
stCreated, stUpdated := scanTimePair()
var label, channel, userID *string
var metaJSON []byte
if err := rows.Scan(&key, &msgCount, stCreated, stUpdated, &label, &channel, &userID, &metaJSON); err != nil {
continue
}
var meta map[string]string
if len(metaJSON) > 0 {
json.Unmarshal(metaJSON, &meta)
}
result = append(result, store.SessionInfo{
Key: key,
MessageCount: msgCount,
Created: stCreated.Time,
Updated: stUpdated.Time,
Label: derefStr(label),
Channel: derefStr(channel),
UserID: derefStr(userID),
Metadata: meta,
})
}
if result == nil {
result = []store.SessionInfo{}
}
return store.SessionListResult{Sessions: result, Total: total}
}
// ListPagedRich returns enriched session info for API responses (includes model, tokens, agent name).
func (s *SQLiteSessionStore) ListPagedRich(ctx context.Context, opts store.SessionListOpts) store.SessionListRichResult {
limit := opts.Limit
if limit <= 0 {
limit = 20
}
offset := max(opts.Offset, 0)
where, whereArgs := buildSessionFilter(opts, "s")
var total int
countQ := "SELECT COUNT(*) FROM sessions s" + where
if err := s.db.QueryRowContext(ctx, countQ, whereArgs...).Scan(&total); err != nil {
return store.SessionListRichResult{Sessions: []store.SessionInfoRich{}, Total: 0}
}
// Use json_array_length and length() instead of PG-specific functions.
const richCols = `s.session_key, json_array_length(s.messages), s.created_at, s.updated_at,
s.label, s.channel, s.user_id, COALESCE(s.metadata, '{}'),
s.model, s.provider, s.input_tokens, s.output_tokens,
COALESCE(a.display_name, ''),
COALESCE(
CAST(json_extract(s.metadata, '$.last_prompt_tokens') AS INTEGER),
length(s.messages) / 4 + 12000
),
COALESCE(a.context_window, 200000),
s.compaction_count`
selectQ := fmt.Sprintf(`SELECT %s
FROM sessions s LEFT JOIN agents a ON s.agent_id = a.id
%s ORDER BY s.updated_at DESC LIMIT ? OFFSET ?`, richCols, where)
selectArgs := append(append([]any{}, whereArgs...), limit, offset)
rows, err := s.db.QueryContext(ctx, selectQ, selectArgs...)
if err != nil {
return store.SessionListRichResult{Sessions: []store.SessionInfoRich{}, Total: total}
}
defer rows.Close()
var result []store.SessionInfoRich
for rows.Next() {
var key string
var msgCount int
stCreated, stUpdated := scanTimePair()
var label, channel, userID *string
var metaJSON []byte
var model, provider *string
var inputTokens, outputTokens int64
var agentName string
var estimatedTokens, contextWindow, compactionCount int
if err := rows.Scan(&key, &msgCount, stCreated, stUpdated, &label, &channel, &userID, &metaJSON,
&model, &provider, &inputTokens, &outputTokens, &agentName,
&estimatedTokens, &contextWindow, &compactionCount); err != nil {
continue
}
var meta map[string]string
if len(metaJSON) > 0 {
json.Unmarshal(metaJSON, &meta)
}
result = append(result, store.SessionInfoRich{
SessionInfo: store.SessionInfo{
Key: key,
MessageCount: msgCount,
Created: stCreated.Time,
Updated: stUpdated.Time,
Label: derefStr(label),
Channel: derefStr(channel),
UserID: derefStr(userID),
Metadata: meta,
},
Model: derefStr(model),
Provider: derefStr(provider),
InputTokens: inputTokens,
OutputTokens: outputTokens,
AgentName: agentName,
EstimatedTokens: estimatedTokens,
ContextWindow: contextWindow,
CompactionCount: compactionCount,
})
}
if result == nil {
result = []store.SessionInfoRich{}
}
return store.SessionListRichResult{Sessions: result, Total: total}
}