Hermes Agent's sessions are read from its SQLite stores — $HERMES_HOME (else ~/.hermes) and each named profile under profiles/, a selected profile home resolving back to its root — through a mode=ro connection, each session parsed in one read transaction for a consistent snapshot while Hermes writes in WAL mode. state.db itself is never modified; SQLite may create an empty state.db-wal and a state.db-shm beside a cleanly closed store, as for OpenCode's. A session's id carries a hash of its store's path, so the same id in two profiles stays two sessions. Titles, messages (respecting Hermes's active/compacted history and model-only display metadata, older schemas' missing columns read as defaults), models and token usage (session_model_usage rows, plus the session counters' main-loop residual) reach the Sessions page and Stats; the cache key includes the WAL's size and time, since usage and titles change without state.db's. Hermes usage is session-level, so it is not in Calls and the ledger is unchanged. Hermes sessions are read only: no resume, no delete, no folder or bar checkbox, with a note saying so (en and zh). Tests: hermes_test, hermes_compaction_test, hermes_display_test, hermes_usage_residual_test, hermes_visibility_test; sessions-manage.test.cjs covers the read-only view. go vet ./internal/..., go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui -count=1 ./... under a temp HOME, and go test -race ./internal/sessions ./internal/usage pass. (#569)
133 lines
4.6 KiB
Go
133 lines
4.6 KiB
Go
package main
|
||
|
||
import (
|
||
"bytes"
|
||
"encoding/json"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
|
||
"github.com/yetone/magpie/internal/agentenv"
|
||
"github.com/yetone/magpie/internal/catalog"
|
||
"github.com/yetone/magpie/internal/sessions"
|
||
"github.com/yetone/magpie/internal/settings"
|
||
)
|
||
|
||
// sessionsHome is a sandbox HOME with the sessions package's fixtures as
|
||
// Claude Code's and Codex's folders, their models priced, dates in UTC.
|
||
func sessionsHome(t *testing.T) time.Time {
|
||
t.Helper()
|
||
h := t.TempDir()
|
||
t.Setenv("HOME", h)
|
||
t.Setenv("USERPROFILE", h)
|
||
t.Setenv("XDG_CONFIG_HOME", filepath.Join(h, ".config"))
|
||
t.Setenv("XDG_CACHE_HOME", filepath.Join(h, ".cache"))
|
||
// OpenCode's and Pi's folders in the sandbox too (HOME isn't the home
|
||
// on Windows)
|
||
t.Setenv("XDG_DATA_HOME", filepath.Join(h, ".local", "share"))
|
||
for _, k := range agentenv.Vars {
|
||
t.Setenv(k, "")
|
||
}
|
||
t.Setenv("PI_CODING_AGENT_DIR", filepath.Join(h, ".pi", "agent"))
|
||
|
||
for from, env := range map[string]string{"claude": "CLAUDE_CONFIG_DIR", "codex": "CODEX_HOME"} {
|
||
dir := filepath.Join(h, "."+from)
|
||
if err := os.CopyFS(dir, os.DirFS(filepath.Join("internal", "sessions", "testdata", from))); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
t.Setenv(env, dir)
|
||
}
|
||
oldZone, oldPrice := time.Local, sessions.PriceOf
|
||
time.Local = time.UTC
|
||
sessions.PriceOf = func(_ settings.Settings, m string) (catalog.Price, bool) {
|
||
switch m {
|
||
case "claude-opus-5-5":
|
||
return catalog.Price{Input: 4, Output: 20, CacheRead: 0.2, CacheWrite: 5}, true
|
||
case "gpt-6-astra":
|
||
return catalog.Price{Input: 10, Output: 50, CacheRead: 1}, true
|
||
}
|
||
return catalog.Price{}, false
|
||
}
|
||
t.Cleanup(func() {
|
||
// the index is written behind the page: let that write finish before
|
||
// the zone it reads (a stat of the file it writes) goes back
|
||
sessions.Reset()
|
||
time.Local, sessions.PriceOf = oldZone, oldPrice
|
||
})
|
||
sessions.Reset()
|
||
return time.Date(2026, 9, 28, 12, 0, 0, 0, time.UTC)
|
||
}
|
||
|
||
func runSessions(t *testing.T, now time.Time, args ...string) string {
|
||
t.Helper()
|
||
var b bytes.Buffer
|
||
if err := sessionsTo(&b, args, now); err != nil {
|
||
t.Fatalf("%v: %v", args, err)
|
||
}
|
||
return b.String()
|
||
}
|
||
|
||
func wantAll(t *testing.T, out string, want ...string) {
|
||
t.Helper()
|
||
for _, w := range want {
|
||
if !strings.Contains(out, w) {
|
||
t.Errorf("missing %q in\n%s", w, out)
|
||
}
|
||
}
|
||
}
|
||
|
||
// magpie sessions lists the latest sessions; --days sums them day by day
|
||
// with the top models and folders; --model and --folder narrow both.
|
||
func TestSessionsCmd(t *testing.T) {
|
||
now := sessionsHome(t)
|
||
|
||
list := runSessions(t, now)
|
||
wantAll(t, list, "ago", "Codex", "it's", "Add a README", "Claude Code", "app", "Fix the login bug in auth.go", "latest 2")
|
||
if strings.Index(list, "Add a README") > strings.Index(list, "Fix the login") {
|
||
t.Error("not the latest first")
|
||
}
|
||
if got := runSessions(t, now, "--folder", "app"); strings.Contains(got, "README") || !strings.Contains(got, "login") {
|
||
t.Errorf("--folder app:\n%s", got)
|
||
}
|
||
|
||
week := runSessions(t, now, "--days", "7")
|
||
wantAll(t, week, "2.1K tokens last 7 days", "cache read 6.0K (75% hit)", "2026-09-22 – 2026-09-28",
|
||
"Tue Sep 22", "Wed Sep 23 2.1K", "Mon Sep 28", "████", "models", "gpt-6-astra", "100%", "folders", "/work/it's")
|
||
if strings.Contains(week, "Sep 21") || strings.Contains(week, "claude-opus") {
|
||
t.Errorf("outside the range:\n%s", week)
|
||
}
|
||
|
||
app := runSessions(t, now, "--days=all", "--folder", "/work/app")
|
||
wantAll(t, app, "all time · /work/app", "Sun Sep 20", "active 5m on 1 day", "claude-opus-5-5", "claude-haiku-4-5-20251001", "not priced: claude-haiku-4-5-20251001")
|
||
if strings.Contains(app, "folders") {
|
||
t.Errorf("folders under a folder:\n%s", app)
|
||
}
|
||
opus := runSessions(t, now, "stats", "--days", "all", "--model", "opus")
|
||
wantAll(t, opus, "all time · claude-opus-5-5", "active —", "folders", "/work/app")
|
||
|
||
var r sessions.Rollup
|
||
if err := json.Unmarshal([]byte(runSessions(t, now, "--days", "today", "--json")), &r); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if r.From != "2026-09-28" || len(r.Days) != 1 || r.Spent() != 0 {
|
||
t.Errorf("today: %+v", r)
|
||
}
|
||
var ss []sessions.Session
|
||
if err := json.Unmarshal([]byte(runSessions(t, now, "--json", "--model", "gpt-6-astra")), &ss); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(ss) != 1 || ss[0].Agent != "codex" || ss[0].Resume == "" {
|
||
t.Errorf("--json --model: %+v", ss)
|
||
}
|
||
|
||
var b bytes.Buffer
|
||
if err := sessionsTo(&b, []string{"--days", "7", "--model", "nope"}, now); err == nil || !strings.Contains(err.Error(), "gpt-6-astra") {
|
||
t.Errorf("an unknown model: %v", err)
|
||
}
|
||
if err := sessionsTo(&b, []string{"--days", "week"}, now); err == nil {
|
||
t.Error("--days week taken")
|
||
}
|
||
}
|