Files
wehub-resource-sync f99010fae1
CI / lint (push) Failing after 1s
CI / frontend (push) Failing after 1s
CI / scripts (push) Failing after 1s
CI / Go Test (ubuntu-latest) (push) Failing after 0s
CI / frontend-node-25 (push) Failing after 1s
CI / docs (push) Failing after 0s
CI / coverage (push) Failing after 0s
CI / e2e (push) Failing after 0s
Docker / build-and-push (push) Failing after 1s
CI / integration (push) Failing after 4m43s
CI / Go Test (windows-latest) (push) Has been cancelled
CI / Desktop Unit Tests (Windows) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux (arm64)) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Windows) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (aarch64)) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (x86_64)) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:30:36 +08:00

1871 lines
58 KiB
Go

package main
import (
"bytes"
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"net"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.kenn.io/agentsview/internal/config"
"go.kenn.io/agentsview/internal/db"
"go.kenn.io/agentsview/internal/dbtest"
"go.kenn.io/agentsview/internal/parser"
"go.kenn.io/agentsview/internal/server"
agentsync "go.kenn.io/agentsview/internal/sync"
"go.kenn.io/kit/daemon"
)
// decodeCLIJSON unmarshals CLI stdout into T, failing the test with the
// raw output when the bytes are not valid JSON. Centralizes the
// "stdout should be valid JSON" diagnostic used across the CLI tests.
func decodeCLIJSON[T any](t *testing.T, out string) T {
t.Helper()
var got T
require.NoError(t, json.Unmarshal([]byte(out), &got),
"stdout should be valid JSON: %q", out)
return got
}
// cliSessionList mirrors the JSON shape emitted by `session list`. Unused
// fields stay at their zero value, so tests that only inspect Sessions or
// NextCursor can decode into the same type.
type cliSessionList struct {
Sessions []map[string]any `json:"sessions"`
NextCursor string `json:"next_cursor"`
Total int `json:"total"`
}
// cliMessageList mirrors the JSON shape emitted by `session messages`.
type cliMessageList struct {
Messages []map[string]any `json:"messages"`
Count int `json:"count"`
}
// cliToolCallList mirrors the JSON shape emitted by `session tool-calls`.
type cliToolCallList struct {
ToolCalls []map[string]any `json:"tool_calls"`
Count int `json:"count"`
}
// sessionUsageCommand resolves the `session usage` cobra command for the
// given full argument slice and parses the flags that follow the session
// id (args[3:]). Callers then pass the command to
// sessionUsageDataForCommand.
func sessionUsageCommand(t *testing.T, args ...string) *cobra.Command {
t.Helper()
root := newRootCommand()
cmd, _, err := root.Find(args)
require.NoError(t, err)
require.NoError(t, cmd.ParseFlags(args[3:]))
return cmd
}
// writeTestConfig writes body to config.toml under dataDir. Used to set up
// auth-token and intentionally-invalid configuration cases.
func writeTestConfig(t *testing.T, dataDir, body string) {
t.Helper()
require.NoError(t, os.WriteFile(
filepath.Join(dataDir, "config.toml"), []byte(body), 0o600,
))
}
// seedUsageSession upserts a session carrying total-output-token data so
// the offline `session usage` path has something to report.
func seedUsageSession(
t *testing.T, d *db.DB, id, project, agent string, outputTokens int,
) {
t.Helper()
require.NoError(t, d.UpsertSession(db.Session{
ID: id,
Project: project,
Machine: "usage-host",
Agent: agent,
MessageCount: 2,
UserMessageCount: 2,
TotalOutputTokens: outputTokens,
HasTotalOutputTokens: true,
}))
}
// pgReadStoreStub records how the patched openPGReadStore was exercised.
type pgReadStoreStub struct {
Opened bool
CleanupCalled bool
PG config.PGConfig
}
// stubPGReadStore replaces openPGReadStore with one that returns store,
// records the PGConfig it was called with, and restores the original on
// cleanup. The caller owns the lifecycle of store; the stub's cleanup only
// flags CleanupCalled so tests can assert the service released the store.
func stubPGReadStore(t *testing.T, store db.Store) *pgReadStoreStub {
t.Helper()
stub := &pgReadStoreStub{}
orig := openPGReadStore
openPGReadStore = func(
_ config.Config, pgCfg config.PGConfig,
) (db.Store, func(), error) {
stub.Opened = true
stub.PG = pgCfg
return store, func() { stub.CleanupCalled = true }, nil
}
t.Cleanup(func() { openPGReadStore = orig })
return stub
}
// forbidPGReadStore replaces openPGReadStore with one that fails the test
// if called, asserting the SQLite path never reaches for PostgreSQL.
func forbidPGReadStore(t *testing.T) {
t.Helper()
orig := openPGReadStore
openPGReadStore = func(
config.Config, config.PGConfig,
) (db.Store, func(), error) {
t.Fatal("openPGReadStore should not be called without --pg")
return nil, nil, nil
}
t.Cleanup(func() { openPGReadStore = orig })
}
// remoteUsageSpec configures newRemoteUsageServer. Zero values fall back to
// the common defaults (codex agent, remote-project, 42 output tokens).
type remoteUsageSpec struct {
canonicalID string // id whose detail and usage routes return 200
agent string // defaults to "codex"
project string // defaults to "remote-project"
outputTokens int // defaults to 42
bearer string // if set, asserts Authorization: Bearer <bearer>
serverRunning bool // include server_running:true in the usage body
usageDelay time.Duration // optional sleep before serving /usage
}
// remoteUsageRequests records what the fake usage server observed.
type remoteUsageRequests struct {
UsagePath string
UsageQuery string
}
// newRemoteUsageServer stands in for a remote agentsview server answering
// the session-detail and session-usage routes that `session usage --server`
// calls. Any unregistered path 404s, which lets bare/raw session ids fall
// through to the canonical lookup the CLI retries.
func newRemoteUsageServer(
t *testing.T, spec remoteUsageSpec,
) (*httptest.Server, *remoteUsageRequests) {
t.Helper()
if spec.agent == "" {
spec.agent = "codex"
}
if spec.project == "" {
spec.project = "remote-project"
}
if spec.outputTokens == 0 {
spec.outputTokens = 42
}
reqs := &remoteUsageRequests{}
detailPath := "/api/v1/sessions/" + spec.canonicalID
usagePath := detailPath + "/usage"
detailJSON := fmt.Sprintf(`{"id":%q,"agent":%q,"project":%q}`,
spec.canonicalID, spec.agent, spec.project)
usageJSON := remoteUsageJSON(spec)
ts := httptest.NewServer(http.HandlerFunc(func(
w http.ResponseWriter, r *http.Request,
) {
if spec.bearer != "" {
assert.Equal(t, "Bearer "+spec.bearer,
r.Header.Get("Authorization"))
} else {
assert.Empty(t, r.Header.Get("Authorization"))
}
switch r.URL.Path {
case detailPath:
writeJSONResponse(w, detailJSON)
case usagePath:
reqs.UsagePath = r.URL.Path
reqs.UsageQuery = r.URL.RawQuery
if spec.usageDelay > 0 {
time.Sleep(spec.usageDelay)
}
writeJSONResponse(w, usageJSON)
default:
http.NotFound(w, r)
}
}))
t.Cleanup(ts.Close)
return ts, reqs
}
// remoteUsageJSON builds the session-usage response body for a
// remoteUsageSpec.
func remoteUsageJSON(spec remoteUsageSpec) string {
server := ""
if spec.serverRunning {
server = `,"server_running":true`
}
return fmt.Sprintf(`{
"session_id": %q,
"agent": %q,
"project": %q,
"total_output_tokens": %d,
"peak_context_tokens": 2048,
"has_token_data": true,
"cost_usd": 0.5,
"has_cost": true,
"models": ["gpt-5.1"],
"unpriced_models": []%s
}`, spec.canonicalID, spec.agent, spec.project, spec.outputTokens, server)
}
func TestSessionHelp_ShowsSubcommands(t *testing.T) {
t.Parallel()
cmd := newRootCommand()
buf := &bytes.Buffer{}
cmd.SetOut(buf)
cmd.SetErr(buf)
cmd.SetArgs([]string{"session", "--help"})
if err := cmd.Execute(); err != nil {
t.Fatal(err)
}
help := buf.String()
for _, name := range []string{
"get", "usage", "list", "messages", "tool-calls",
"export", "sync", "watch",
} {
assert.Contains(t, help, name,
"expected subcommand %q in help", name)
}
assert.Contains(t, help, "--format",
"expected --format persistent flag in help")
assert.Contains(t, help, "--pg",
"expected --pg persistent flag in help")
}
// seedSession opens the SQLite DB at dataDir/sessions.db, inserts
// one session with the given id+project (plus sane defaults), and
// closes the DB. Each subtest gets its own dataDir so parallel
// runs don't step on each other.
func seedSession(t *testing.T, dataDir, id, project string) {
t.Helper()
seedSessionWithOpts(t, dataDir, id, project, nil)
}
type sessionSeed struct {
id string
project string
mut func(*db.Session)
}
func seedEmptyArchive(t *testing.T, dataDir string) {
t.Helper()
dbtest.EnsureTestDBAt(t, sessionsDBPath(dataDir))
registerSQLiteDaemonRuntime(t, dataDir)
}
// seedSessionWithOpts is like seedSession but allows mutation of
// the db.Session before insert via the optional mut callback.
// Use this when a test needs to set signal counts or other
// non-default fields (e.g. ToolFailureSignalCount = 0 to
// exercise the --min-tool-failures flag's *int handling).
func seedSessionWithOpts(
t *testing.T, dataDir, id, project string,
mut func(*db.Session),
) {
t.Helper()
seedSessionsWithOpts(t, dataDir, sessionSeed{
id: id,
project: project,
mut: mut,
})
}
func seedSessionsWithOpts(t *testing.T, dataDir string, seeds ...sessionSeed) {
t.Helper()
seedSessionArchiveRows(t, dataDir, seeds...)
registerSQLiteDaemonRuntime(t, dataDir)
}
func seedSessionArchiveRows(t *testing.T, dataDir string, seeds ...sessionSeed) {
t.Helper()
dbPath := sessionsDBPath(dataDir)
dbtest.EnsureTestDBAt(t, dbPath)
d, err := db.Open(dbPath)
require.NoError(t, err)
closed := false
t.Cleanup(func() {
if !closed {
_ = d.Close()
}
})
for _, seed := range seeds {
// UserMessageCount >= 2 so seeded sessions pass the default
// ExcludeOneShot filter in `session list` (one-shot means
// user_message_count <= 1). See internal/db/analytics.go.
s := db.Session{
ID: seed.id,
Project: seed.project,
Machine: "m",
Agent: "claude",
MessageCount: 4,
UserMessageCount: 2,
}
if seed.mut != nil {
seed.mut(&s)
}
require.NoError(t, d.UpsertSession(s))
}
err = d.Close()
closed = true
require.NoError(t, err)
}
func registerSQLiteDaemonRuntime(t *testing.T, dataDir string) {
t.Helper()
registerSQLiteDaemonRuntimeWithEngine(t, dataDir, false)
}
func registerSQLiteWritableDaemonRuntime(t *testing.T, dataDir string) {
t.Helper()
registerSQLiteDaemonRuntimeWithEngine(t, dataDir, true)
}
func registerSQLiteDaemonRuntimeWithEngine(
t *testing.T, dataDir string, writable bool,
) {
t.Helper()
cfg, err := config.LoadMinimal()
require.NoError(t, err)
if cfg.DataDir != dataDir {
cfg.DataDir = dataDir
cfg.DBPath = sessionsDBPath(dataDir)
}
database, err := openDB(cfg)
require.NoError(t, err)
ln, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
port := ln.Addr().(*net.TCPAddr).Port
cfg.Host = "127.0.0.1"
cfg.Port = port
cfg.WriteTimeout = 30 * time.Second
var engine *agentsync.Engine
if writable {
engine = agentsync.NewEngine(database, agentsync.EngineConfig{
Ephemeral: true,
})
}
srv := server.New(cfg, database, engine)
ts := httptest.NewUnstartedServer(srv.Handler())
ts.Listener = ln
ts.Start()
t.Cleanup(func() {
ts.Close()
database.Close()
RemoveDaemonRuntime(dataDir)
})
registerSyncRouteTestRuntime(t, dataDir, ts.URL)
}
func TestSessionGetVariants(t *testing.T) {
dataDir := newAgentDataDir(t)
bareID := "019da6a6-8c67-7c23-b102-ef48502852d0"
seedSessionsWithOpts(t, dataDir,
sessionSeed{id: "s-1", project: "proj"},
sessionSeed{id: "s-json", project: "proj"},
sessionSeed{id: "s-2", project: "proj"},
sessionSeed{
id: "codex:" + bareID,
project: "proj",
mut: func(s *db.Session) { s.Agent = "codex" },
},
)
t.Run("json format", func(t *testing.T) {
out, err := executeCommand(newRootCommand(),
"session", "get", "s-1", "--format", "json")
require.NoError(t, err)
got := decodeCLIJSON[map[string]any](t, out)
assert.Equal(t, "s-1", got["id"])
assert.Equal(t, "proj", got["project"])
})
t.Run("json alias", func(t *testing.T) {
out, err := executeCommand(newRootCommand(),
"session", "get", "s-json", "--json")
require.NoError(t, err)
got := decodeCLIJSON[map[string]any](t, out)
assert.Equal(t, "s-json", got["id"])
assert.Equal(t, "proj", got["project"])
})
t.Run("missing", func(t *testing.T) {
_, err := executeCommand(newRootCommand(),
"session", "get", "missing", "--format", "json")
require.Error(t, err)
assert.Contains(t, err.Error(), "missing")
assert.Contains(t, err.Error(), "not found")
})
t.Run("human", func(t *testing.T) {
out, err := executeCommand(newRootCommand(),
"session", "get", "s-2")
require.NoError(t, err)
assert.True(t, strings.Contains(out, "s-2"),
"human output should contain session id, got: %q", out)
assert.True(t, strings.Contains(out, "proj"),
"human output should contain project, got: %q", out)
})
// Covers the case where a user passes a bare UUID (e.g. copied from a
// Codex session file name) for a session whose stored ID carries an
// agent prefix. The CLI retries the lookup with each registered IDPrefix.
t.Run("bare id finds prefixed", func(t *testing.T) {
out, err := executeCommand(newRootCommand(),
"session", "get", bareID, "--format", "json")
require.NoError(t, err)
got := decodeCLIJSON[map[string]any](t, out)
assert.Equal(t, "codex:"+bareID, got["id"])
})
}
func TestSessionList_ReadOnlyFixture(t *testing.T) {
dataDir := newAgentDataDir(t)
seedSessionsWithOpts(t, dataDir,
sessionSeed{id: "s-a", project: "shape"},
sessionSeed{id: "s-b", project: "shape"},
sessionSeed{id: "s-c", project: "shape"},
sessionSeed{id: "p1-only", project: "p1"},
sessionSeed{id: "lo", project: "sort-count", mut: func(s *db.Session) {
s.MessageCount = 2
}},
sessionSeed{id: "mid", project: "sort-count", mut: func(s *db.Session) {
s.MessageCount = 5
}},
sessionSeed{id: "hi", project: "sort-count", mut: func(s *db.Session) {
s.MessageCount = 9
}},
sessionSeed{id: "a", project: "sort-multi", mut: func(s *db.Session) {
s.MessageCount = 1
s.StartedAt = new("2024-03-01T00:00:00Z")
}},
sessionSeed{id: "b", project: "sort-multi", mut: func(s *db.Session) {
s.MessageCount = 1
s.StartedAt = new("2024-01-01T00:00:00Z")
}},
sessionSeed{id: "c", project: "sort-multi", mut: func(s *db.Session) {
s.MessageCount = 2
s.StartedAt = new("2024-02-01T00:00:00Z")
}},
sessionSeed{id: "old", project: "sort-empty", mut: func(s *db.Session) {
s.EndedAt = new("2024-01-01T00:00:00Z")
}},
sessionSeed{id: "new", project: "sort-empty", mut: func(s *db.Session) {
s.EndedAt = new("2024-03-01T00:00:00Z")
}},
)
t.Run("json shape", func(t *testing.T) {
out, err := executeCommand(newRootCommand(),
"session", "list", "--project", "shape", "--format", "json")
require.NoError(t, err)
got := decodeCLIJSON[cliSessionList](t, out)
assert.Equal(t, 3, got.Total)
assert.Len(t, got.Sessions, 3)
})
t.Run("cold read-only cursor round trip", func(t *testing.T) {
t.Setenv("AGENTSVIEW_NO_DAEMON", "1")
out, err := executeCommand(newRootCommand(),
"session", "list", "--project", "shape",
"--format", "json", "--limit", "1")
require.NoError(t, err)
first := decodeCLIJSON[cliSessionList](t, out)
require.NotEmpty(t, first.NextCursor)
out, err = executeCommand(newRootCommand(),
"session", "list", "--project", "shape", "--format", "json",
"--limit", "1", "--cursor", first.NextCursor)
require.NoError(t, err)
second := decodeCLIJSON[cliSessionList](t, out)
assert.Len(t, second.Sessions, 1)
})
t.Run("filter by project", func(t *testing.T) {
out, err := executeCommand(newRootCommand(),
"session", "list", "--project", "p1", "--format", "json")
require.NoError(t, err)
got := decodeCLIJSON[cliSessionList](t, out)
require.Len(t, got.Sessions, 1)
assert.Equal(t, "p1-only", got.Sessions[0]["id"])
})
t.Run("sort and reverse", func(t *testing.T) {
// --sort messages defaults to ascending.
out, err := executeCommand(newRootCommand(),
"session", "list", "--project", "sort-count",
"--sort", "messages", "--format", "json")
require.NoError(t, err)
assert.Equal(t, []string{"lo", "mid", "hi"},
sessionListIDs(t, out))
// --reverse flips it to descending.
out, err = executeCommand(newRootCommand(),
"session", "list", "--project", "sort-count",
"--sort", "messages", "--reverse", "--format", "json")
require.NoError(t, err)
assert.Equal(t, []string{"hi", "mid", "lo"},
sessionListIDs(t, out))
// -r is the shorthand for --reverse.
out, err = executeCommand(newRootCommand(),
"session", "list", "--project", "sort-count",
"--sort", "messages", "-r", "--format", "json")
require.NoError(t, err)
assert.Equal(t, []string{"hi", "mid", "lo"},
sessionListIDs(t, out))
})
t.Run("multi-key sort", func(t *testing.T) {
// Per-key directions: messages asc, then started desc.
out, err := executeCommand(newRootCommand(),
"session", "list", "--project", "sort-multi",
"--sort", "messages:asc,started:desc", "--format", "json")
require.NoError(t, err)
assert.Equal(t, []string{"a", "b", "c"},
sessionListIDs(t, out))
// --reverse flips only the unsuffixed key (messages -> desc); the
// explicit started:asc is left untouched.
out, err = executeCommand(newRootCommand(),
"session", "list", "--project", "sort-multi",
"--sort", "messages,started:asc", "-r", "--format", "json")
require.NoError(t, err)
assert.Equal(t, []string{"c", "b", "a"},
sessionListIDs(t, out))
})
t.Run("empty sort reverse", func(t *testing.T) {
// Default recent is newest-first.
out, err := executeCommand(newRootCommand(),
"session", "list", "--project", "sort-empty",
"--sort", "", "--format", "json")
require.NoError(t, err)
assert.Equal(t, []string{"new", "old"}, sessionListIDs(t, out))
// --reverse on the empty (default) sort flips recent to
// oldest-first.
out, err = executeCommand(newRootCommand(),
"session", "list", "--project", "sort-empty",
"--sort", "", "--reverse", "--format", "json")
require.NoError(t, err)
assert.Equal(t, []string{"old", "new"}, sessionListIDs(t, out))
})
t.Run("invalid sort", func(t *testing.T) {
_, err := executeCommand(newRootCommand(),
"session", "list", "--project", "sort-count",
"--sort", "bogus", "--format", "json")
require.Error(t, err)
assert.Contains(t, err.Error(), "invalid sort")
})
}
func TestSessionList_ServerFlagUsesHTTP(t *testing.T) {
dataDir := newAgentDataDir(t)
seedSession(t, dataDir, "local-session", "local")
var gotPath, gotProject string
ts := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
gotProject = r.URL.Query().Get("project")
assert.Equal(t, http.MethodGet, r.Method)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"sessions": [
{"id":"remote-session","project":"remote","agent":"claude"}
],
"total": 1
}`))
}))
defer ts.Close()
out, err := executeCommand(newRootCommand(),
"session", "list", "--server", ts.URL, "--project", "remote",
"--json")
require.NoError(t, err)
got := decodeCLIJSON[cliSessionList](t, out)
assert.Equal(t, "/api/v1/sessions", gotPath)
assert.Equal(t, "remote", gotProject)
assert.Equal(t, 1, got.Total)
require.Len(t, got.Sessions, 1)
assert.Equal(t, "remote-session", got.Sessions[0]["id"])
}
func TestSessionList_ServerFlagDoesNotSendConfigAuthToken(t *testing.T) {
dataDir := newAgentDataDir(t)
t.Setenv("AGENTSVIEW_SERVER_TOKEN", "")
writeTestConfig(t, dataDir, `auth_token = "local-secret"`)
ts := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
assert.Empty(t, r.Header.Get("Authorization"))
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"sessions":[],"total":0}`))
}))
defer ts.Close()
_, err := executeCommand(newRootCommand(),
"session", "list", "--server", ts.URL, "--json")
require.NoError(t, err)
}
func TestSessionList_ServerFlagDoesNotLoadLocalConfig(t *testing.T) {
dataDir := newAgentDataDir(t)
writeTestConfig(t, dataDir, `not = valid = toml`)
ts := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"sessions":[],"total":0}`))
}))
defer ts.Close()
_, err := executeCommand(newRootCommand(),
"session", "list", "--server", ts.URL, "--json")
require.NoError(t, err)
}
func TestSessionList_ServerTokenSendsBearer(t *testing.T) {
newAgentDataDir(t)
t.Setenv("AGENTSVIEW_SERVER_TOKEN", "remote-secret")
ts := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "Bearer remote-secret",
r.Header.Get("Authorization"))
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"sessions":[],"total":0}`))
}))
defer ts.Close()
_, err := executeCommand(newRootCommand(),
"session", "list", "--server", ts.URL, "--json")
require.NoError(t, err)
}
func TestSessionList_PGFlagUsesPGReadStore(t *testing.T) {
localDir := newAgentDataDir(t)
remoteDir := t.TempDir()
t.Setenv("AGENTSVIEW_PG_URL", "postgres://example.test/agentsview")
t.Setenv("AGENTSVIEW_PG_SCHEMA", "custom_schema")
seedSessionArchiveRows(t, localDir,
sessionSeed{id: "local-session", project: "local"})
seedSessionArchiveRows(t, remoteDir,
sessionSeed{id: "pg-session", project: "remote"})
remoteDB := dbtest.OpenTestDBAt(t, sessionsDBPath(remoteDir))
stub := stubPGReadStore(t, remoteDB)
out, err := executeCommand(newRootCommand(),
"session", "list", "--pg", "--format", "json")
require.NoError(t, err)
got := decodeCLIJSON[cliSessionList](t, out)
assert.Equal(t, 1, got.Total)
require.Len(t, got.Sessions, 1)
assert.Equal(t, "pg-session", got.Sessions[0]["id"])
assert.Equal(t, "postgres://example.test/agentsview", stub.PG.URL)
assert.Equal(t, "custom_schema", stub.PG.Schema)
assert.True(t, stub.CleanupCalled, "expected PG store cleanup")
}
func TestSessionList_ConfiguredPGWithoutFlagUsesSQLite(t *testing.T) {
localDir := newAgentDataDir(t)
t.Setenv("AGENTSVIEW_PG_URL", "postgres://example.test/from-env")
seedSession(t, localDir, "local-session", "local")
forbidPGReadStore(t)
out, err := executeCommand(newRootCommand(),
"session", "list", "--format", "json")
require.NoError(t, err)
got := decodeCLIJSON[cliSessionList](t, out)
assert.Equal(t, 1, got.Total)
require.Len(t, got.Sessions, 1)
assert.Equal(t, "local-session", got.Sessions[0]["id"])
}
func TestSessionList_PGFlagRequiresURL(t *testing.T) {
newAgentDataDir(t)
t.Setenv("AGENTSVIEW_PG_URL", "")
_, err := executeCommand(newRootCommand(),
"session", "list", "--pg")
require.Error(t, err)
assert.Contains(t, err.Error(), "pg url not configured")
assert.Contains(t, err.Error(), "AGENTSVIEW_PG_URL")
}
func TestSessionList_DefaultDoesNotOpenPGStore(t *testing.T) {
dataDir := newAgentDataDir(t)
t.Setenv("AGENTSVIEW_PG_URL", "")
seedSession(t, dataDir, "local-session", "local")
forbidPGReadStore(t)
out, err := executeCommand(newRootCommand(),
"session", "list", "--format", "json")
require.NoError(t, err)
got := decodeCLIJSON[cliSessionList](t, out)
assert.Equal(t, 1, got.Total)
require.Len(t, got.Sessions, 1)
assert.Equal(t, "local-session", got.Sessions[0]["id"])
}
func TestPGReadServiceClosesStoreWhenOpenFailsAfterCleanupProvided(t *testing.T) {
closed := false
orig := openPGReadStore
openPGReadStore = func(
config.Config, config.PGConfig,
) (db.Store, func(), error) {
return nil, func() { closed = true },
errors.New("schema check failed")
}
t.Cleanup(func() {
openPGReadStore = orig
})
_, _, err := newPGReadService(config.Config{}, config.PGConfig{
URL: "postgres://example.test/agentsview",
Schema: "agentsview",
})
require.Error(t, err)
assert.Contains(t, err.Error(), "opening pg store")
assert.True(t, closed, "expected cleanup on error-after-open path")
}
// TestSessionList_MinToolFailuresZero verifies that passing
// --min-tool-failures 0 is treated as an explicit filter value
// (sessions with >=0 failures) rather than skipped as the int
// zero value. This exercises the cmd.Flags().Changed() guard
// that converts the int flag into a *int on ListFilter.
func TestSessionList_MinToolFailuresZero(t *testing.T) {
dataDir := newAgentDataDir(t)
seedSessionWithOpts(t, dataDir, "s-a", "proj",
func(s *db.Session) { s.ToolFailureSignalCount = 0 })
out, err := executeCommand(newRootCommand(),
"session", "list", "--min-tool-failures", "0",
"--format", "json")
require.NoError(t, err)
got := decodeCLIJSON[cliSessionList](t, out)
require.Len(t, got.Sessions, 1)
assert.Equal(t, "s-a", got.Sessions[0]["id"])
}
// seedMessages inserts n message rows for sessionID with alternating
// user/assistant roles, ordinals starting at 1, and RFC3339
// timestamps one minute apart starting at 2026-04-01T00:00:00Z.
func seedMessages(t *testing.T, dataDir, sessionID string, n int) {
t.Helper()
d, err := db.Open(filepath.Join(dataDir, "sessions.db"))
require.NoError(t, err)
t.Cleanup(func() { d.Close() })
base := time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC)
msgs := make([]db.Message, 0, n)
for i := range n {
role := "user"
if i%2 == 1 {
role = "assistant"
}
content := fmt.Sprintf("msg-%d", i+1)
msgs = append(msgs, db.Message{
SessionID: sessionID,
Ordinal: i + 1,
Role: role,
Content: content,
ContentLength: len(content),
Timestamp: base.Add(time.Duration(i) * time.Minute).Format(time.RFC3339),
})
}
require.NoError(t, d.InsertMessages(msgs))
require.NoError(t, d.Close())
}
func TestSessionMessagesVariants(t *testing.T) {
dataDir := newAgentDataDir(t)
seedSession(t, dataDir, "s-msgs", "proj")
seedMessages(t, dataDir, "s-msgs", 5)
t.Run("json shape", func(t *testing.T) {
out, err := executeCommand(newRootCommand(),
"session", "messages", "s-msgs", "--format", "json")
require.NoError(t, err)
got := decodeCLIJSON[cliMessageList](t, out)
assert.Equal(t, 5, got.Count)
require.Len(t, got.Messages, 5)
assert.Equal(t, float64(1), got.Messages[0]["ordinal"])
})
t.Run("from and limit", func(t *testing.T) {
out, err := executeCommand(newRootCommand(),
"session", "messages", "s-msgs",
"--from", "3", "--limit", "2", "--format", "json")
require.NoError(t, err)
got := decodeCLIJSON[cliMessageList](t, out)
assert.Equal(t, 2, got.Count)
require.Len(t, got.Messages, 2)
assert.Equal(t, float64(3), got.Messages[0]["ordinal"])
})
t.Run("direction desc", func(t *testing.T) {
out, err := executeCommand(newRootCommand(),
"session", "messages", "s-msgs",
"--direction", "desc", "--format", "json")
require.NoError(t, err)
got := decodeCLIJSON[cliMessageList](t, out)
assert.Equal(t, 5, got.Count)
require.Len(t, got.Messages, 5)
assert.Equal(t, float64(5), got.Messages[0]["ordinal"])
assert.Equal(t, float64(4), got.Messages[1]["ordinal"])
assert.Equal(t, float64(3), got.Messages[2]["ordinal"])
assert.Equal(t, float64(2), got.Messages[3]["ordinal"])
assert.Equal(t, float64(1), got.Messages[4]["ordinal"])
})
}
// seedMessagesWithToolCalls inserts one assistant message for sessionID
// carrying n tool_use blocks, numbered 1..n with ToolName "Bash<i>".
// Ordinals start at 1. Timestamp is fixed for determinism.
func seedMessagesWithToolCalls(
t *testing.T, dataDir, sessionID string, n int,
) {
t.Helper()
d, err := db.Open(filepath.Join(dataDir, "sessions.db"))
require.NoError(t, err)
t.Cleanup(func() { d.Close() })
calls := make([]db.ToolCall, 0, n)
for i := range n {
calls = append(calls, db.ToolCall{
SessionID: sessionID,
ToolName: fmt.Sprintf("Bash%d", i+1),
Category: "shell",
ToolUseID: fmt.Sprintf("tu-%d", i+1),
InputJSON: `{"command":"echo hi"}`,
})
}
msg := db.Message{
SessionID: sessionID,
Ordinal: 1,
Role: "assistant",
Content: "",
ContentLength: 0,
Timestamp: "2026-04-01T00:00:00Z",
HasToolUse: true,
ToolCalls: calls,
}
require.NoError(t, d.InsertMessages([]db.Message{msg}))
require.NoError(t, d.Close())
}
func TestSessionToolCallsVariants(t *testing.T) {
dataDir := newAgentDataDir(t)
seedSession(t, dataDir, "s-tc", "proj")
seedMessagesWithToolCalls(t, dataDir, "s-tc", 2)
t.Run("json shape", func(t *testing.T) {
out, err := executeCommand(newRootCommand(),
"session", "tool-calls", "s-tc", "--format", "json")
require.NoError(t, err)
got := decodeCLIJSON[cliToolCallList](t, out)
assert.Equal(t, 2, got.Count)
require.Len(t, got.ToolCalls, 2)
assert.NotEmpty(t, got.ToolCalls[0]["tool_name"])
assert.NotEmpty(t, got.ToolCalls[0]["timestamp"])
})
t.Run("human table", func(t *testing.T) {
out, err := executeCommand(newRootCommand(),
"session", "tool-calls", "s-tc")
require.NoError(t, err)
for _, token := range []string{
"ORDINAL", "TIMESTAMP", "TOOL", "CATEGORY",
"Bash1", "Bash2",
} {
assert.Contains(t, out, token,
"human output should contain %q, got: %q", token, out)
}
})
}
func TestSessionExport_StreamsFromDisk(t *testing.T) {
dataDir := newAgentDataDir(t)
src := filepath.Join(t.TempDir(), "session.jsonl")
body := "{\"type\":\"user\",\"content\":\"hello\"}\n" +
"{\"type\":\"assistant\",\"content\":\"hi\"}\n"
require.NoError(t, os.WriteFile(src, []byte(body), 0o600))
seedSessionWithOpts(t, dataDir, "s-1", "proj",
func(s *db.Session) { s.FilePath = &src })
out, err := executeCommand(newRootCommand(),
"session", "export", "s-1")
require.NoError(t, err)
assert.Equal(t, body, out)
}
func createHermesExportStateDB(t *testing.T, root string) string {
t.Helper()
sessionsDir := filepath.Join(root, "sessions")
require.NoError(t, os.MkdirAll(sessionsDir, 0o755))
dbPath := filepath.Join(root, "state.db")
conn, err := sql.Open("sqlite3", dbPath)
require.NoError(t, err)
defer func() { _ = conn.Close() }()
_, err = conn.Exec(`
CREATE TABLE sessions (
id TEXT PRIMARY KEY,
source TEXT NOT NULL,
model TEXT,
parent_session_id TEXT,
started_at REAL NOT NULL,
ended_at REAL,
message_count INTEGER DEFAULT 0,
input_tokens INTEGER DEFAULT 0,
output_tokens INTEGER DEFAULT 0,
cache_read_tokens INTEGER DEFAULT 0,
cache_write_tokens INTEGER DEFAULT 0,
reasoning_tokens INTEGER DEFAULT 0,
estimated_cost_usd REAL,
actual_cost_usd REAL,
cost_status TEXT,
cost_source TEXT,
title TEXT,
api_call_count INTEGER DEFAULT 0
);
CREATE TABLE messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
role TEXT NOT NULL,
content TEXT,
tool_call_id TEXT,
tool_calls TEXT,
timestamp REAL NOT NULL,
finish_reason TEXT,
reasoning TEXT,
reasoning_content TEXT,
reasoning_details TEXT,
codex_reasoning_items TEXT,
codex_message_items TEXT
);
INSERT INTO sessions (
id, source, model, started_at, ended_at, message_count, title
) VALUES
('child', 'profile', 'gpt-5.4', 1778767200.0, 1778767800.0, 1, 'Child Session'),
('sibling', 'profile', 'gpt-5.4', 1778767200.0, 1778767800.0, 1, 'Sibling Session');
INSERT INTO messages (
session_id, role, content, timestamp
) VALUES
('child', 'user', 'target hermes message', 1778767210.0),
('sibling', 'user', 'sibling hermes message', 1778767211.0);
`)
require.NoError(t, err)
return dbPath
}
func TestSessionExportHermesStateDB(t *testing.T) {
dataDir := newAgentDataDir(t)
wrongRoot := t.TempDir()
_ = createHermesExportStateDB(t, wrongRoot)
root := t.TempDir()
dbPath := createHermesExportStateDB(t, root)
t.Setenv("HERMES_SESSIONS_DIR", filepath.Join(wrongRoot, "sessions"))
seedSessionWithOpts(t, dataDir, "hermes:child", "proj",
func(s *db.Session) {
s.Agent = string(parser.AgentHermes)
s.SourceSessionID = "child"
s.SourceVersion = "hermes-state-db"
s.FilePath = &dbPath
})
out, err := executeCommand(newRootCommand(),
"session", "export", "hermes:child")
require.NoError(t, err)
assert.NotContains(t, out, "SQLite format 3")
assert.Contains(t, out, `"role":"session_meta"`)
assert.Contains(t, out, "target hermes message")
assert.NotContains(t, out, "sibling hermes message")
assert.NotContains(t, out, "wrong root message")
for line := range strings.SplitSeq(strings.TrimSpace(out), "\n") {
assert.JSONEq(t, line, line)
}
}
func TestSessionExportHermesStateDBWithoutSourceVersion(t *testing.T) {
dataDir := newAgentDataDir(t)
root := t.TempDir()
dbPath := createHermesExportStateDB(t, root)
seedSessionWithOpts(t, dataDir, "hermes:child", "proj",
func(s *db.Session) {
s.Agent = string(parser.AgentHermes)
s.FilePath = &dbPath
})
out, err := executeCommand(newRootCommand(),
"session", "export", "hermes:child")
require.NoError(t, err)
assert.NotContains(t, out, "SQLite format 3")
assert.Contains(t, out, `"role":"session_meta"`)
assert.Contains(t, out, "target hermes message")
assert.NotContains(t, out, "sibling hermes message")
for line := range strings.SplitSeq(strings.TrimSpace(out), "\n") {
assert.JSONEq(t, line, line)
}
}
func TestSessionExport_AiderVirtualPathStreamsOnlySelectedRun(t *testing.T) {
dataDir := newAgentDataDir(t)
repo := filepath.Join(t.TempDir(), "repo")
require.NoError(t, os.MkdirAll(repo, 0o755))
history := filepath.Join(repo, parser.AiderHistoryFileName())
run0 := "# aider chat started at 2026-06-09 14:01:00\n" +
"#### first prompt\nanswer one\n"
run1 := "# aider chat started at 2026-06-09 15:30:00\n" +
"#### second prompt\nanswer two\n"
run2 := "# aider chat started at 2026-06-09 16:45:00\n" +
"#### third prompt\nanswer three\n"
require.NoError(t, os.WriteFile(
history, []byte("ignored preamble\n"+run0+run1+run2), 0o600,
))
rawID, ok := parser.AiderRawIDAt(history, 1)
require.True(t, ok, "run 1 raw ID")
seedSessionWithOpts(t, dataDir, "aider:"+rawID, "repo",
func(s *db.Session) {
s.Agent = string(parser.AgentAider)
vp := parser.AiderVirtualPath(history, 1)
s.FilePath = &vp
})
out, err := executeCommand(newRootCommand(),
"session", "export", "aider:"+rawID)
require.NoError(t, err)
assert.Equal(t, run1, out)
assert.NotContains(t, out, "first prompt")
assert.NotContains(t, out, "third prompt")
}
func TestSessionExport_AiderStaleIndexReResolvesBySessionID(t *testing.T) {
dataDir := newAgentDataDir(t)
repo := filepath.Join(t.TempDir(), "repo")
require.NoError(t, os.MkdirAll(repo, 0o755))
history := filepath.Join(repo, parser.AiderHistoryFileName())
run0 := "# aider chat started at 2026-06-09 14:01:00\n" +
"#### first prompt\nanswer one\n"
run1 := "# aider chat started at 2026-06-09 15:30:00\n" +
"#### second prompt\nanswer two\n"
require.NoError(t, os.WriteFile(history, []byte(run0+run1), 0o600))
rawID, ok := parser.AiderRawIDAt(history, 1)
require.True(t, ok, "run 1 raw ID")
seedSessionWithOpts(t, dataDir, "aider:"+rawID, "repo",
func(s *db.Session) {
s.Agent = string(parser.AgentAider)
vp := parser.AiderVirtualPath(history, 1)
s.FilePath = &vp
})
inserted := "# aider chat started at 2026-06-09 13:00:00\n" +
"#### inserted prompt\ninserted answer\n"
require.NoError(t, os.WriteFile(
history, []byte(inserted+run0+run1), 0o600,
))
out, err := executeCommand(newRootCommand(),
"session", "export", "aider:"+rawID)
require.NoError(t, err)
assert.Equal(t, run1, out)
assert.NotContains(t, out, "inserted prompt")
assert.NotContains(t, out, "first prompt")
}
func TestSessionExport_FailsWhenSourceMissing(t *testing.T) {
dataDir := newAgentDataDir(t)
nonExistent := filepath.Join(t.TempDir(), "gone.jsonl")
seedSessionWithOpts(t, dataDir, "s-1", "proj",
func(s *db.Session) { s.FilePath = &nonExistent })
_, err := executeCommand(newRootCommand(),
"session", "export", "s-1")
require.Error(t, err)
assert.Contains(t, err.Error(), "source file not found")
}
func TestSessionExport_FailsWhenNotInLocalArchive(t *testing.T) {
newAgentDataDir(t)
_, err := executeCommand(newRootCommand(),
"session", "export", "unknown-id")
require.Error(t, err)
assert.Contains(t, err.Error(), "not in local archive")
assert.Contains(t, err.Error(), "unknown-id")
}
// Export inherits --format/--json from the session group but streams raw
// bytes, so it must reject both rather than silently ignore them.
func TestSessionExport_RejectsFormatFlag(t *testing.T) {
for _, flag := range [][]string{{"--format", "json"}, {"--json"}} {
t.Run(strings.Join(flag, " "), func(t *testing.T) {
newAgentDataDir(t)
args := append([]string{"session", "export", "some-id"}, flag...)
_, err := executeCommand(newRootCommand(), args...)
require.Error(t, err)
assert.Contains(t, err.Error(),
"--format/--json not supported")
})
}
}
func TestSessionExport_RejectsPGFlag(t *testing.T) {
newAgentDataDir(t)
t.Setenv("AGENTSVIEW_PG_URL", "postgres://example.test/agentsview")
_, err := executeCommand(newRootCommand(),
"session", "export", "some-id", "--pg")
require.Error(t, err)
assert.Contains(t, err.Error(), "local-only command")
assert.Contains(t, err.Error(), "--pg not supported")
}
func TestSessionUsage_ServerFlagUsesHTTP(t *testing.T) {
newAgentDataDir(t)
ts, reqs := newRemoteUsageServer(t, remoteUsageSpec{
canonicalID: "remote-session",
serverRunning: true,
})
cmd := sessionUsageCommand(t,
"session", "usage", "remote-session", "--server", ts.URL)
out, code, err := sessionUsageDataForCommand(cmd, "remote-session")
require.NoError(t, err)
require.NotNil(t, out)
assert.Equal(t, "/api/v1/sessions/remote-session/usage", reqs.UsagePath)
assert.Equal(t, "breakdown=true", reqs.UsageQuery,
"remote CLI must request full breakdown rows")
assert.Equal(t, tokenUseExitOK, code)
assert.Equal(t, "remote-session", out.SessionID)
assert.Equal(t, "remote-project", out.Project)
assert.Equal(t, 42, out.TotalOutputTokens)
assert.True(t, out.ServerRunning)
}
func TestSessionUsage_UsesDiscoveredDaemon(t *testing.T) {
dataDir := newAgentDataDir(t)
var gotUsagePath string
ts := sessionUsageRuntimeServer(t, func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/api/v1/sessions/remote-session":
_, _ = w.Write([]byte(`{
"id": "remote-session",
"agent": "codex",
"project": "remote-project"
}`))
case "/api/v1/sessions/remote-session/usage":
gotUsagePath = r.URL.Path
_, _ = w.Write([]byte(`{
"session_id": "remote-session",
"agent": "codex",
"project": "remote-project",
"total_output_tokens": 42,
"peak_context_tokens": 2048,
"has_token_data": true,
"cost_usd": 0.5,
"has_cost": true,
"models": ["gpt-5.1"],
"unpriced_models": []
}`))
default:
t.Errorf("unexpected path: %s", r.URL.Path)
http.NotFound(w, r)
}
})
registerSyncRouteTestRuntime(t, dataDir, ts.URL)
cmd := sessionUsageCommand(t, "session", "usage", "remote-session")
out, code, err := sessionUsageDataForCommand(cmd, "remote-session")
require.NoError(t, err)
require.NotNil(t, out)
assert.Equal(t, "/api/v1/sessions/remote-session/usage", gotUsagePath)
assert.Equal(t, tokenUseExitOK, code)
assert.Equal(t, "remote-session", out.SessionID)
assert.True(t, out.ServerRunning)
assert.NoFileExists(t, filepath.Join(dataDir, "sessions.db"))
}
func TestSessionUsage_DefaultRefusesReadOnlyDaemon(t *testing.T) {
dataDir := newAgentDataDir(t)
var gotUsagePath string
ts := sessionUsageRuntimeServer(t, func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, "/usage") {
gotUsagePath = r.URL.Path
}
http.NotFound(w, r)
})
registerTestRuntime(t, dataDir, ts.URL, true)
cmd := sessionUsageCommand(t, "session", "usage", "remote-session")
out, code, err := sessionUsageDataForCommand(cmd, "remote-session")
require.Error(t, err)
assert.Nil(t, out)
assert.Equal(t, tokenUseExitErr, code)
assert.Contains(t, err.Error(), "read-only")
assert.Contains(t, err.Error(), "use --pg")
assert.Empty(t, gotUsagePath)
assert.NoFileExists(t, filepath.Join(dataDir, "sessions.db"))
}
func TestTokenUse_UsesDiscoveredDaemon(t *testing.T) {
dataDir := newAgentDataDir(t)
var gotUsagePath string
ts := sessionUsageRuntimeServer(t, func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/api/v1/sessions/remote-session":
_, _ = w.Write([]byte(`{
"id": "remote-session",
"agent": "codex",
"project": "remote-project"
}`))
case "/api/v1/sessions/remote-session/usage":
gotUsagePath = r.URL.Path
_, _ = w.Write([]byte(`{
"session_id": "remote-session",
"agent": "codex",
"project": "remote-project",
"total_output_tokens": 42,
"peak_context_tokens": 2048,
"has_token_data": true,
"cost_usd": 0.5,
"has_cost": true,
"models": ["gpt-5.1"],
"unpriced_models": []
}`))
default:
t.Errorf("unexpected path: %s", r.URL.Path)
http.NotFound(w, r)
}
})
registerSyncRouteTestRuntime(t, dataDir, ts.URL)
out, code, err := sessionUsageData("remote-session")
require.NoError(t, err)
require.NotNil(t, out)
assert.Equal(t, "/api/v1/sessions/remote-session/usage", gotUsagePath)
assert.Equal(t, tokenUseExitOK, code)
assert.Equal(t, "remote-session", out.SessionID)
assert.True(t, out.ServerRunning)
assert.NoFileExists(t, filepath.Join(dataDir, "sessions.db"))
}
func TestTokenUse_RefusesReadOnlyDaemon(t *testing.T) {
dataDir := newAgentDataDir(t)
var gotUsagePath string
ts := sessionUsageRuntimeServer(t, func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, "/usage") {
gotUsagePath = r.URL.Path
}
http.NotFound(w, r)
})
registerTestRuntime(t, dataDir, ts.URL, true)
out, code, err := sessionUsageData("remote-session")
require.Error(t, err)
assert.Nil(t, out)
assert.Equal(t, tokenUseExitErr, code)
assert.Contains(t, err.Error(), "read-only")
assert.Contains(t, err.Error(), "use --pg")
assert.Empty(t, gotUsagePath)
assert.NoFileExists(t, filepath.Join(dataDir, "sessions.db"))
}
func sessionUsageRuntimeServer(
t *testing.T,
sessionHandler http.HandlerFunc,
) *httptest.Server {
t.Helper()
ping := daemon.NewPingHandler(daemon.PingHandlerOptions{
Service: daemonService,
Version: "test",
})
ts := httptest.NewServer(http.HandlerFunc(func(
w http.ResponseWriter,
r *http.Request,
) {
if r.URL.Path == "/api/ping" {
ping.ServeHTTP(w, r)
return
}
sessionHandler(w, r)
}))
t.Cleanup(ts.Close)
return ts
}
func TestSessionUsage_ServerFlagResolvesBareID(t *testing.T) {
newAgentDataDir(t)
const bareID = "019da6a6-8c67-7c23-b102-ef48502852d0"
const canonicalID = "codex:" + bareID
ts, reqs := newRemoteUsageServer(t, remoteUsageSpec{
canonicalID: canonicalID,
serverRunning: true,
})
cmd := sessionUsageCommand(t,
"session", "usage", bareID, "--server", ts.URL)
out, code, err := sessionUsageDataForCommand(cmd, bareID)
require.NoError(t, err)
require.NotNil(t, out)
assert.Equal(t, tokenUseExitOK, code)
assert.Equal(t, canonicalID, out.SessionID)
assert.Equal(t, "/api/v1/sessions/"+canonicalID+"/usage", reqs.UsagePath)
}
func TestSessionUsage_ServerFlagResolvesKimiRawID(t *testing.T) {
newAgentDataDir(t)
const rawID = "project-hash:session-uuid"
const canonicalID = "kimi:" + rawID
ts, reqs := newRemoteUsageServer(t, remoteUsageSpec{
canonicalID: canonicalID,
agent: "kimi",
outputTokens: 84,
serverRunning: true,
})
cmd := sessionUsageCommand(t,
"session", "usage", rawID, "--server", ts.URL)
out, code, err := sessionUsageDataForCommand(cmd, rawID)
require.NoError(t, err)
require.NotNil(t, out)
assert.Equal(t, tokenUseExitOK, code)
assert.Equal(t, canonicalID, out.SessionID)
assert.Equal(t, "/api/v1/sessions/"+canonicalID+"/usage", reqs.UsagePath)
}
func TestSessionUsage_ServerFlagDoesNotSendConfigAuthToken(t *testing.T) {
dataDir := newAgentDataDir(t)
t.Setenv("AGENTSVIEW_SERVER_TOKEN", "")
writeTestConfig(t, dataDir, `auth_token = "local-secret"`)
ts, _ := newRemoteUsageServer(t, remoteUsageSpec{
canonicalID: "remote-session",
serverRunning: true,
})
cmd := sessionUsageCommand(t,
"session", "usage", "remote-session", "--server", ts.URL)
out, _, err := sessionUsageDataForCommand(cmd, "remote-session")
require.NoError(t, err)
require.NotNil(t, out)
}
func TestSessionUsage_ServerFlagDoesNotLoadLocalConfig(t *testing.T) {
dataDir := newAgentDataDir(t)
writeTestConfig(t, dataDir, `not = valid = toml`)
ts, _ := newRemoteUsageServer(t, remoteUsageSpec{
canonicalID: "remote-session",
serverRunning: true,
})
cmd := sessionUsageCommand(t,
"session", "usage", "remote-session", "--server", ts.URL)
out, _, err := sessionUsageDataForCommand(cmd, "remote-session")
require.NoError(t, err)
require.NotNil(t, out)
}
func TestSessionUsage_ServerTokenSendsBearer(t *testing.T) {
dataDir := newAgentDataDir(t)
tokenFile := filepath.Join(dataDir, "remote-token")
require.NoError(t, os.WriteFile(
tokenFile, []byte("remote-secret\n"), 0o600,
))
ts, _ := newRemoteUsageServer(t, remoteUsageSpec{
canonicalID: "remote-session",
bearer: "remote-secret",
serverRunning: true,
})
cmd := sessionUsageCommand(t,
"session", "usage", "remote-session",
"--server", ts.URL,
"--server-token-file", tokenFile)
out, _, err := sessionUsageDataForCommand(cmd, "remote-session")
require.NoError(t, err)
require.NotNil(t, out)
}
func TestSessionUsage_ServerHTTPClientHasTimeout(t *testing.T) {
oldClient := sessionUsageHTTPClient
sessionUsageHTTPClient = &http.Client{Timeout: 20 * time.Millisecond}
t.Cleanup(func() { sessionUsageHTTPClient = oldClient })
newAgentDataDir(t)
ts, _ := newRemoteUsageServer(t, remoteUsageSpec{
canonicalID: "remote-session",
usageDelay: 200 * time.Millisecond,
})
cmd := sessionUsageCommand(t,
"session", "usage", "remote-session", "--server", ts.URL)
start := time.Now()
out, code, err := sessionUsageDataForCommand(cmd, "remote-session")
elapsed := time.Since(start)
require.Error(t, err)
assert.Nil(t, out)
assert.Equal(t, tokenUseExitErr, code)
assert.Less(t, elapsed, 150*time.Millisecond)
}
func TestSessionUsage_ConfiguredPGWithoutFlagUsesSQLite(t *testing.T) {
dataDir := newAgentDataDir(t)
t.Setenv("AGENTSVIEW_PG_URL", "postgres://example.test/agentsview")
t.Setenv("AGENTSVIEW_NO_DAEMON", "1")
localDB := dbtest.OpenTestDBAt(t, sessionsDBPath(dataDir))
seedUsageSession(t, localDB, "local-session", "local-project", "codex", 24)
forbidPGReadStore(t)
cmd := sessionUsageCommand(t, "session", "usage", "local-session")
out, code, err := sessionUsageDataForCommand(cmd, "local-session")
require.NoError(t, err)
require.NotNil(t, out)
assert.Equal(t, tokenUseExitOK, code)
assert.Equal(t, "local-session", out.SessionID)
assert.Equal(t, "local-project", out.Project)
assert.Equal(t, 24, out.TotalOutputTokens)
assert.False(t, out.ServerRunning)
}
func TestSessionUsage_PGFlagUsesPGStore(t *testing.T) {
dataDir := newAgentDataDir(t)
t.Setenv("AGENTSVIEW_PG_URL", "postgres://example.test/agentsview")
pgDB := dbtest.OpenTestDBAt(t, filepath.Join(dataDir, "pg.db"))
seedUsageSession(t, pgDB, "pg-session", "pg-project", "codex", 42)
stub := stubPGReadStore(t, pgDB)
cmd := sessionUsageCommand(t, "session", "usage", "pg-session", "--pg")
out, code, err := sessionUsageDataForCommand(cmd, "pg-session")
require.NoError(t, err)
require.NotNil(t, out)
assert.True(t, stub.Opened, "expected session usage --pg to open PG store")
assert.Equal(t, "postgres://example.test/agentsview", stub.PG.URL)
assert.Equal(t, tokenUseExitOK, code)
assert.Equal(t, "pg-session", out.SessionID)
assert.Equal(t, "pg-project", out.Project)
assert.Equal(t, 42, out.TotalOutputTokens)
assert.False(t, out.ServerRunning)
}
func TestSessionUsage_PGFlagResolvesBareSessionID(t *testing.T) {
dataDir := newAgentDataDir(t)
t.Setenv("AGENTSVIEW_PG_URL", "postgres://example.test/agentsview")
bareID := "019da6a6-8c67-7c23-b102-ef48502852d0"
storedID := "codex:" + bareID
pgDB := dbtest.OpenTestDBAt(t, filepath.Join(dataDir, "pg.db"))
seedUsageSession(t, pgDB, storedID, "pg-project", "codex", 42)
stubPGReadStore(t, pgDB)
cmd := sessionUsageCommand(t, "session", "usage", bareID, "--pg")
out, code, err := sessionUsageDataForCommand(cmd, bareID)
require.NoError(t, err)
require.NotNil(t, out)
assert.Equal(t, tokenUseExitOK, code)
assert.Equal(t, storedID, out.SessionID)
assert.Equal(t, "pg-project", out.Project)
assert.Equal(t, 42, out.TotalOutputTokens)
}
func TestSessionUsage_PGFlagResolvesColonBearingRawSessionID(t *testing.T) {
dataDir := newAgentDataDir(t)
t.Setenv("AGENTSVIEW_PG_URL", "postgres://example.test/agentsview")
rawID := "project-hash:session-uuid"
storedID := "kimi:" + rawID
pgDB := dbtest.OpenTestDBAt(t, filepath.Join(dataDir, "pg.db"))
seedUsageSession(t, pgDB, storedID, "pg-project", "kimi", 84)
stubPGReadStore(t, pgDB)
cmd := sessionUsageCommand(t, "session", "usage", rawID, "--pg")
out, code, err := sessionUsageDataForCommand(cmd, rawID)
require.NoError(t, err)
require.NotNil(t, out)
assert.Equal(t, tokenUseExitOK, code)
assert.Equal(t, storedID, out.SessionID)
assert.Equal(t, "pg-project", out.Project)
assert.Equal(t, 84, out.TotalOutputTokens)
}
// TestSessionSync_UnknownID_ReportsNoFilePath verifies that the
// sync engine is plumbed in direct mode. No daemon running, no
// sessions in DB — Execute returns an error whose message contains
// "no file_path recorded" AND the missing id. Critically the
// error must NOT be db.ErrReadOnly (that would mean the engine
// was nil, i.e. direct-backend constructed without a real
// sync.Engine as in the default newService path).
func TestSessionSync_UnknownID_ReportsNoFilePath(t *testing.T) {
newAgentDataDir(t)
t.Setenv("AGENTSVIEW_NO_DAEMON", "1")
_, err := executeCommand(newRootCommand(),
"session", "sync", "missing-id")
require.Error(t, err)
assert.Contains(t, err.Error(), "missing-id")
assert.Contains(t, err.Error(), "no file_path recorded",
"error should come from directBackend.Sync validation, not ErrReadOnly")
assert.NotContains(t, err.Error(), "read-only",
"engine must be plumbed; got ErrReadOnly-style message: %v", err)
}
func TestSessionSync_PGFlagRefusesWrite(t *testing.T) {
newAgentDataDir(t)
t.Setenv("AGENTSVIEW_PG_URL", "postgres://example.test/agentsview")
_, err := executeCommand(newRootCommand(),
"session", "sync", "--pg", "some-id")
require.Error(t, err)
assert.Contains(t, err.Error(), "--pg is read-only")
assert.Contains(t, err.Error(), "write commands")
}
func TestSessionSync_ServerFlagTreatsPathShapedArgAsRemotePath(t *testing.T) {
dataDir := newAgentDataDir(t)
writeTestConfig(t, dataDir, `not = valid = toml`)
var got struct {
ID string `json:"id"`
Path string `json:"path"`
}
ts := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, http.MethodPost, r.Method)
assert.Equal(t, "/api/v1/sessions/sync", r.URL.Path)
require.NoError(t, json.NewDecoder(r.Body).Decode(&got))
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"id": "remote-session",
"agent": "codex",
"project": "remote-project"
}`))
}))
defer ts.Close()
out, err := executeCommand(newRootCommand(),
"session", "sync", "--server", ts.URL,
"/remote/session.jsonl", "--json")
require.NoError(t, err)
assert.Contains(t, out, `"id":"remote-session"`)
assert.Empty(t, got.ID)
assert.Equal(t, "/remote/session.jsonl", got.Path)
}
// TestSessionSync_AgainstReadOnlyDaemon_Refuses verifies the CLI
// refuses to sync when a pg serve (ReadOnly=true) daemon owns
// the runtime record. Discovery uses the shared daemon ping
// endpoint, so the fixture must answer /api/ping.
func TestSessionSync_AgainstReadOnlyDaemon_Refuses(t *testing.T) {
dataDir := daemonRuntimeDir(t)
t.Setenv("AGENTSVIEW_DATA_DIR", dataDir)
host, port := testPingServer(t)
writeDaemonRuntimeForTest(t, dataDir, host, port, "test", true)
_, err := executeCommand(newRootCommand(),
"session", "sync", "some-id")
require.Error(t, err)
assert.Contains(t, err.Error(), "read-only",
"should refuse against pg serve daemon")
}
// TestSessionSync_WhenDaemonRuntimeUnprobeable_Refuses verifies that
// an unprobeable writable runtime record still suppresses direct writes.
func TestSessionSync_WhenDaemonRuntimeUnprobeable_Refuses(t *testing.T) {
dataDir := daemonRuntimeDir(t)
t.Setenv("AGENTSVIEW_DATA_DIR", dataDir)
// Bind then immediately close so the port is guaranteed
// free and no TCP listener is accepting.
ln, port := freeTCPListener(t)
ln.Close()
_, err := WriteDaemonRuntime(
dataDir, "127.0.0.1", port, "test", false,
)
require.NoError(t, err)
t.Cleanup(func() { RemoveDaemonRuntime(dataDir) })
_, err = executeCommand(newRootCommand(),
"session", "sync", "some-id")
require.Error(t, err)
assert.Contains(t, err.Error(), "not responding",
"should refuse against unreachable active daemon")
assert.NotContains(t, err.Error(), "no file_path",
"must not fall through to direct-write engine")
}
func TestSessionSync_ColdArchiveWriteAutoStartsDaemon(t *testing.T) {
dataDir := daemonRuntimeDir(t)
t.Setenv("AGENTSVIEW_DATA_DIR", dataDir)
var syncCalled bool
ts := httptest.NewServer(http.HandlerFunc(func(
w http.ResponseWriter, r *http.Request,
) {
require.Equal(t, "/api/v1/sessions/sync", r.URL.Path)
require.Equal(t, http.MethodPost, r.Method)
syncCalled = true
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"id":"daemon-synced"}`))
}))
t.Cleanup(ts.Close)
rt := daemonRuntimeFromTestURL(t, ts.URL)
oldStart := startBackgroundServeForTransport
startBackgroundServeForTransport = func(
_ context.Context, cfg *config.Config, timeout time.Duration,
) (*DaemonRuntime, error) {
assert.Equal(t, dataDir, cfg.DataDir)
assert.Equal(t, backgroundAutoStartReadyTimeout, timeout)
return rt, nil
}
t.Cleanup(func() { startBackgroundServeForTransport = oldStart })
out, err := executeCommand(newRootCommand(),
"session", "sync", "some-id")
require.NoError(t, err)
assert.True(t, syncCalled)
assert.Contains(t, out, "synced: daemon-synced")
assert.NoFileExists(t, filepath.Join(dataDir, "sessions.db"))
}
func daemonRuntimeFromTestURL(t *testing.T, rawURL string) *DaemonRuntime {
t.Helper()
u, err := url.Parse(rawURL)
require.NoError(t, err)
host, portText, err := net.SplitHostPort(u.Host)
require.NoError(t, err)
port, err := strconv.Atoi(portText)
require.NoError(t, err)
return &DaemonRuntime{Host: host, Port: port}
}
// TestSessionWatch_ExitsOnCancel verifies that `session watch`
// exits cleanly when the cobra Command's context is cancelled,
// without hanging on the upstream channel. Any NDJSON emitted
// to stdout must parse as one JSON object per line. We don't
// drive DB changes here (poll interval is 1.5s) — this test
// only asserts the plumbing: service resolution, channel wiring,
// and the shutdown path.
//
// To distinguish a real Watch call from an early-return stub, we
// also assert the command runs past a short delay: any stub that
// returns synchronously would complete in single-digit ms.
func TestSessionWatch_ExitsOnCancel(t *testing.T) {
dataDir := newAgentDataDir(t)
seedSession(t, dataDir, "s-watch", "proj")
root := newRootCommand()
buf := &bytes.Buffer{}
root.SetOut(buf)
root.SetErr(buf)
root.SetArgs([]string{"session", "watch", "s-watch"})
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go func() {
time.Sleep(50 * time.Millisecond)
cancel()
}()
start := time.Now()
done := make(chan error, 1)
go func() { done <- root.ExecuteContext(ctx) }()
var execErr error
select {
case execErr = <-done:
case <-time.After(3 * time.Second):
t.Fatal("session watch did not exit within 3s after ctx cancel")
}
elapsed := time.Since(start)
// Clean cancellation must surface as either nil (upstream channel
// closed on ctx cancel) or an error that wraps context.Canceled.
// Anything else indicates a regression that earlier versions of
// this test swallowed by discarding execErr.
if execErr != nil && !errors.Is(execErr, context.Canceled) {
t.Fatalf("expected nil or context.Canceled, got %v", execErr)
}
// A stub that returns immediately would complete far faster
// than the cancel delay. Require the command to actually
// wait on the Watch channel.
assert.GreaterOrEqual(t, elapsed, 30*time.Millisecond,
"session watch returned too quickly (%v) — "+
"likely a stub, not a real Watch", elapsed)
// Any output must be valid NDJSON. Empty output is fine.
for line := range bytes.SplitSeq(buf.Bytes(), []byte("\n")) {
if len(line) == 0 {
continue
}
var ev map[string]any
require.NoError(t, json.Unmarshal(line, &ev),
"non-NDJSON line: %q", line)
}
}
// TestSessionWatch_UnknownID_FailsFast verifies that `session
// watch` against an unknown session id fails fast with a clear
// "session not found" error rather than returning an indefinitely
// live heartbeat stream. Slow-failure mode would be a contract
// footgun for automation scripts.
func TestSessionWatch_UnknownID_FailsFast(t *testing.T) {
dataDir := newAgentDataDir(t)
seedEmptyArchive(t, dataDir)
_, err := executeCommand(newRootCommand(),
"session", "watch", "unknown-id")
require.Error(t, err)
assert.Contains(t, err.Error(), "session not found",
"expected 'session not found' error; got: %v", err)
assert.Contains(t, err.Error(), "unknown-id",
"error should name the missing session id")
}
// Watch streams a fixed NDJSON format, so it rejects --format/--json
// inherited from the session group. The guard fires before any service
// resolution, so no archive setup is needed.
func TestSessionWatch_RejectsFormatFlag(t *testing.T) {
for _, flag := range [][]string{{"--format", "json"}, {"--json"}} {
t.Run(strings.Join(flag, " "), func(t *testing.T) {
newAgentDataDir(t)
args := append([]string{"session", "watch", "some-id"}, flag...)
_, err := executeCommand(newRootCommand(), args...)
require.Error(t, err)
assert.Contains(t, err.Error(),
"--format/--json not supported")
})
}
}
// TestLooksLikePath covers both POSIX and Windows-style separators
// so "./session.jsonl" works on Windows and bare session IDs stay
// classified as IDs regardless of platform.
func TestLooksLikePath(t *testing.T) {
cases := []struct {
in string
want bool
}{
{"abc-123", false},
{"550e8400-e29b-41d4-a716-446655440000", false},
{"codex:my-session", false},
{".", true},
{"..", true},
{"./session.jsonl", true},
{"../parent/session.jsonl", true},
{`.\session.jsonl`, true},
{`..\parent\session.jsonl`, true},
{"subdir/session.jsonl", true},
{`subdir\session.jsonl`, true},
{"/abs/path.jsonl", true},
}
for _, tc := range cases {
t.Run(tc.in, func(t *testing.T) {
if got := looksLikePath(tc.in); got != tc.want {
t.Fatalf("looksLikePath(%q) = %v, want %v",
tc.in, got, tc.want)
}
})
}
}