Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions cmd/agentsview/archive_query_backend.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,12 @@ type archiveQueryBackend interface {

// sessionUsageQuery selects the session and the attribution scope for
// `session usage`. OwnOnly restores the pre-rollup behavior of reporting
// just the named transcript's own rows.
// just the named transcript's own rows. NoSync skips source refreshes while
// preserving the selected attribution scope.
type sessionUsageQuery struct {
SessionID string
OwnOnly bool
NoSync bool
}

type dailyUsageQuery struct {
Expand Down Expand Up @@ -261,7 +263,7 @@ func (b localArchiveQueryBackend) SessionUsage(
ctx, b.database, b.cfg.AgentDirs, query.SessionID,
)

if known && !b.skipFreshData {
if known && !b.skipFreshData && !query.NoSync {
engine := sync.NewEngine(b.database, sync.EngineConfig{
AgentDirs: b.cfg.AgentDirs,
SourceMachines: b.cfg.SourceMachines,
Expand Down
16 changes: 16 additions & 0 deletions cmd/agentsview/archive_query_backend_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,16 @@ func TestLocalSessionUsageRefreshesSubagentTranscripts(t *testing.T) {
String()),
0o644))

// Passive reads must leave newly written source transcripts unindexed.
archived, _, err := backend.SessionUsage(
ctx, sessionUsageQuery{SessionID: "parent-uuid", NoSync: true})
require.NoError(t, err)
require.NotNil(t, archived)
assert.Zero(t, archived.SubagentCount)
unsynced, err := database.GetSession(ctx, "agent-worker1")
require.NoError(t, err)
assert.Nil(t, unsynced)

out, _, err := backend.SessionUsage(
ctx, sessionUsageQuery{SessionID: "parent-uuid"})
require.NoError(t, err)
Expand All @@ -229,6 +239,12 @@ func TestLocalSessionUsageRefreshesSubagentTranscripts(t *testing.T) {
require.NotNil(t, child.ParentSessionID)
assert.Equal(t, "parent-uuid", *child.ParentSessionID)

archived, _, err = backend.SessionUsage(
ctx, sessionUsageQuery{SessionID: "parent-uuid", NoSync: true})
require.NoError(t, err)
require.NotNil(t, archived)
assert.Equal(t, out.SessionUsage, archived.SessionUsage)

// --own-only skips the subagent refresh and the combined view.
own, _, err := backend.SessionUsage(
ctx, sessionUsageQuery{SessionID: "parent-uuid", OwnOnly: true})
Expand Down
149 changes: 149 additions & 0 deletions cmd/agentsview/session_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1330,6 +1330,155 @@ func TestSessionUsage_ServerFlagUsesHTTP(t *testing.T) {
assert.True(t, out.ServerRunning)
}

func TestSessionUsage_NoSyncPreservesAuthenticatedResolution(t *testing.T) {
dataDir := newAgentDataDir(t)
tokenFile := filepath.Join(dataDir, "remote-token")
require.NoError(t, os.WriteFile(tokenFile, []byte("test-token\n"), 0o600))
const rawID = "session-uuid"
const canonicalID = "codex:" + rawID
ts, reqs := newRemoteUsageServer(t, remoteUsageSpec{
canonicalID: canonicalID,
bearer: "test-token",
})
cmd := sessionUsageCommand(t, "session", "usage", rawID,
"--server", ts.URL, "--server-token-file", tokenFile, "--no-sync")

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, 42, out.TotalOutputTokens)
assert.Equal(t, "breakdown=true&subagents=true", reqs.UsageQuery)
assert.Empty(t, reqs.SyncInput.ID)
assert.NotContains(t, reqs.RequestPath, "/api/v1/sessions/sync")
assert.Contains(t, reqs.RequestPath, "/api/v1/sessions/"+rawID)
assert.Contains(t, reqs.RequestPath, "/api/v1/sessions/"+canonicalID)
}

func TestSessionUsage_NoSyncAutostartDisablesSourceSync(t *testing.T) {
testDataDir(t)
ts, reqs := newRemoteUsageServer(t, remoteUsageSpec{canonicalID: "remote-session"})
u, err := url.Parse(ts.URL)
require.NoError(t, err)
host, portText, err := net.SplitHostPort(u.Host)
require.NoError(t, err)
port, err := strconv.Atoi(portText)
require.NoError(t, err)
var started bool
stubStartBackgroundServeForTransport(t, func(
_ context.Context, cfg *config.Config, _ time.Duration,
) (*DaemonRuntime, error) {
started = true
assert.True(t, cfg.NoSync)
return &DaemonRuntime{Host: host, Port: port}, nil
})
cmd := sessionUsageCommand(t, "session", "usage", "remote-session", "--no-sync")
out, code, err := sessionUsageDataForCommand(cmd, "remote-session")
require.NoError(t, err)
require.NotNil(t, out)
assert.True(t, started)
assert.Equal(t, tokenUseExitOK, code)
assert.Empty(t, reqs.SyncInput.ID)
assert.Equal(t, "breakdown=true&subagents=true", reqs.UsageQuery)
}

func TestSessionUsage_NoSyncDiscoveredDaemon(t *testing.T) {
for _, tc := range []struct {
name string
status int
body string
code int
wantError bool
}{
{"subagent usage", http.StatusOK,
`{"session_id":"codex:parent","total_output_tokens":24,"has_token_data":true,"subagent_count":2}`,
tokenUseExitOK, false},
{"no data", http.StatusOK,
`{"session_id":"codex:parent","has_token_data":false,"has_cost":false}`,
tokenUseExitNoTokenData, false},
{"missing", http.StatusNotFound, "", tokenUseExitNotFound, false},
{"unauthorized", http.StatusUnauthorized, "", tokenUseExitErr, true},
} {
t.Run(tc.name, func(t *testing.T) {
dataDir := newAgentDataDir(t)
writeTestConfig(t, dataDir, `auth_token = "test-token"`)
var paths []string
ts := sessionUsageRuntimeServer(t, func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, http.MethodGet, r.Method)
assert.Equal(t, "Bearer test-token", r.Header.Get("Authorization"))
paths = append(paths, r.URL.Path)
switch r.URL.Path {
case "/api/v1/sessions/codex:parent":
writeJSONResponse(w, `{"id":"codex:parent","agent":"codex"}`)
case "/api/v1/sessions/codex:parent/usage":
assert.Equal(t, "breakdown=true&subagents=true", r.URL.RawQuery)
w.WriteHeader(tc.status)
_, _ = w.Write([]byte(tc.body))
default:
http.NotFound(w, r)
}
})
registerSyncRouteTestRuntime(t, dataDir, ts.URL)
cmd := sessionUsageCommand(t, "session", "usage", "codex:parent", "--no-sync")
out, code, err := sessionUsageDataForCommand(cmd, "codex:parent")
if tc.wantError {
require.Error(t, err)
} else {
require.NoError(t, err)
}
assert.Equal(t, tc.code, code)
assert.Equal(t, []string{
"/api/v1/sessions/codex:parent",
"/api/v1/sessions/codex:parent/usage",
}, paths)
if tc.code == tokenUseExitOK {
require.NotNil(t, out)
assert.Equal(t, 24, out.TotalOutputTokens)
assert.Equal(t, 2, out.SubagentCount)
}
})
}
}

func TestSessionUsage_NoSyncLocalAttributionAndMissingData(t *testing.T) {
for _, tc := range []struct {
name string
id string
ownOnly bool
code int
output int
children int
}{
{"subagent usage", "claude:parent-only", false, tokenUseExitOK, 24, 1},
{"own only", "claude:parent-only", true, tokenUseExitNoTokenData, 0, 0},
{"missing", "claude:missing", false, tokenUseExitNotFound, 0, 0},
} {
t.Run(tc.name, func(t *testing.T) {
dataDir := newAgentDataDir(t)
localDB := dbtest.OpenTestDBAt(t, sessionsDBPath(dataDir))
seedSubagentOnlyUsage(t, localDB, "claude:parent-only", "agent-child", 24)
backend := localArchiveQueryBackend{
cfg: config.Config{DBPath: sessionsDBPath(dataDir)},
database: localDB,
offline: true,
}
out, code, err := backend.SessionUsage(context.Background(), sessionUsageQuery{
SessionID: tc.id, OwnOnly: tc.ownOnly, NoSync: true,
})
require.NoError(t, err)
assert.Equal(t, tc.code, code)
if tc.code == tokenUseExitNotFound {
assert.Nil(t, out)
return
}
require.NotNil(t, out)
assert.Equal(t, tc.output, out.TotalOutputTokens)
assert.Equal(t, tc.children, out.SubagentCount)
})
}
}

func TestSessionUsage_ServerFlagRejectsCombinedUsageFromOlderDaemon(
t *testing.T,
) {
Expand Down
10 changes: 8 additions & 2 deletions cmd/agentsview/session_usage.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ func newSessionUsageCommand() *cobra.Command {
runSessionUsage(cmd, args[0], outputFormat(cmd))
},
}
cmd.Flags().Bool("no-sync", false,
"Use archived usage without synchronizing source transcripts")
cmd.Flags().Bool("own-only", false,
"Report only this session's own usage, excluding subagents")
return cmd
Expand Down Expand Up @@ -89,7 +91,10 @@ func sessionUsageDataForCommand(
}

ownOnly, _ := cmd.Flags().GetBool("own-only")
query := sessionUsageQuery{SessionID: sessionID, OwnOnly: ownOnly}
noSync, _ := cmd.Flags().GetBool("no-sync")
query := sessionUsageQuery{
SessionID: sessionID, OwnOnly: ownOnly, NoSync: noSync,
}

remote, _ := cmd.Flags().GetString("server")
if remote != "" {
Expand Down Expand Up @@ -126,6 +131,7 @@ func sessionUsageDataForCommand(
ctx,
cfg,
archiveQueryPolicy{
NoSync: noSync,
AutoStart: true,
ReadOnlyDaemon: archiveQueryUseReadOnlyDaemon,
DirectReadOnlyAction: "refresh session usage directly",
Expand Down Expand Up @@ -186,7 +192,7 @@ func httpSessionUsageData(
}
return nil, tokenUseExitErr, err
}
if !query.OwnOnly {
if !query.OwnOnly && !query.NoSync {
backend := service.NewHTTPBackend(baseURL, token, false)
if _, syncErr := backend.Sync(ctx, service.SyncInput{
ID: resolvedID, Subagents: true,
Expand Down
13 changes: 11 additions & 2 deletions docs/session-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -675,7 +675,7 @@ request.
returns from the rollup branch first, so `subagents` has no effect.

```bash
agentsview session usage <id> [--format json] [--own-only]
agentsview session usage <id> [--format json] [--own-only] [--no-sync]
```

```json
Expand Down Expand Up @@ -843,9 +843,18 @@ Before querying, the local backend refreshes the session's own transcript and
the `agent-*.jsonl` files under its `subagents/` directory, so a session that
just finished reports complete numbers. `--own-only` skips the subagent refresh.

Pass `--no-sync` to read archived usage without refreshing source transcripts.
This preserves the full subagent rollup and the exit codes above; combine it
with `--own-only` only when you want to exclude subagents. Local and remote
HTTP queries skip the sync request, so recent usage appears after the watcher
or a separate sync has indexed it. PostgreSQL reads already use archived data.
The local `--no-sync` path requires a compatible daemon and starts one with
source synchronization disabled if needed. With `AGENTSVIEW_NO_DAEMON=1`,
it requires an existing compatible daemon.

The command uses a writable local daemon when one is running, or starts a
detached daemon when fresh local data is needed and no compatible daemon is
running. With `AGENTSVIEW_NO_DAEMON=1`, it falls back to direct local SQLite
running. Without `--no-sync`, `AGENTSVIEW_NO_DAEMON=1` selects direct local SQLite
after acquiring the write-owner lock for any required refresh. Configured
PostgreSQL does not change this command's default local behavior; pass `--pg` to
read usage from the shared PostgreSQL store. With `--server`, it calls
Expand Down