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
1871 lines
58 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|
|
}
|