Skip to content

Commit 45e840e

Browse files
committed
feat(usage): report complete delegated session costs
Session usage undercounted delegation because Claude Code stores each subagent in a separate transcript. Include descendant subagents by default across local, server, PostgreSQL, and DuckDB views while keeping aggregate reports unchanged. The --own-only flag preserves the previous per-transcript view. Also account for billed Claude web searches and restore the deprecated cost_usd compatibility field so supported backends report consistent costs.
1 parent d00ae43 commit 45e840e

48 files changed

Lines changed: 4133 additions & 207 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

cmd/agentsview/archive_query_backend.go

Lines changed: 55 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"go.kenn.io/agentsview/internal/parser"
1313
"go.kenn.io/agentsview/internal/pricing"
1414
"go.kenn.io/agentsview/internal/pricingrefresh"
15+
"go.kenn.io/agentsview/internal/service"
1516
"go.kenn.io/agentsview/internal/sync"
1617
)
1718

@@ -34,7 +35,15 @@ type archiveQueryPolicy struct {
3435
type archiveQueryBackend interface {
3536
ActivityReport(context.Context, ActivityReportConfig) (activity.Report, error)
3637
DailyUsage(context.Context, dailyUsageQuery) (db.DailyUsageResult, error)
37-
SessionUsage(context.Context, string) (*sessionUsageOutput, int, error)
38+
SessionUsage(context.Context, sessionUsageQuery) (*sessionUsageOutput, int, error)
39+
}
40+
41+
// sessionUsageQuery selects the session and the attribution scope for
42+
// `session usage`. OwnOnly restores the pre-rollup behavior of reporting
43+
// just the named transcript's own rows.
44+
type sessionUsageQuery struct {
45+
SessionID string
46+
OwnOnly bool
3847
}
3948

4049
type dailyUsageQuery struct {
@@ -186,9 +195,9 @@ func (b daemonArchiveQueryBackend) DailyUsage(
186195

187196
func (b daemonArchiveQueryBackend) SessionUsage(
188197
ctx context.Context,
189-
sessionID string,
198+
query sessionUsageQuery,
190199
) (*sessionUsageOutput, int, error) {
191-
return httpSessionUsageData(ctx, b.tr.URL, b.authToken, sessionID)
200+
return httpSessionUsageData(ctx, b.tr.URL, b.authToken, query)
192201
}
193202

194203
type localArchiveQueryBackend struct {
@@ -238,13 +247,13 @@ func localDailyUsageFilter(query dailyUsageQuery) db.UsageFilter {
238247

239248
func (b localArchiveQueryBackend) SessionUsage(
240249
ctx context.Context,
241-
sessionID string,
250+
query sessionUsageQuery,
242251
) (*sessionUsageOutput, int, error) {
243252
applyCustomPricing(b.database, b.cfg)
244253
ensureUsagePricing(b.database, b.offline, b.cfg.CustomModelPricing)
245254

246255
resolvedID, known := resolveRawSessionID(
247-
ctx, b.database, b.cfg.AgentDirs, sessionID,
256+
ctx, b.database, b.cfg.AgentDirs, query.SessionID,
248257
)
249258

250259
if known && !b.skipFreshData {
@@ -260,18 +269,29 @@ func (b localArchiveQueryBackend) SessionUsage(
260269
fmt.Fprintf(os.Stderr,
261270
"warning: sync failed: %v\n", syncErr)
262271
}
272+
if !query.OwnOnly {
273+
syncSubagentTranscripts(ctx, engine, b.database, resolvedID)
274+
}
263275
// Flush pending debounced signal recomputes before the
264276
// usage query reads the session.
265277
engine.Close()
266278
}
267279

268-
u, err := b.database.GetSessionUsage(ctx, resolvedID, true)
280+
load := func() (*db.SessionUsage, error) {
281+
if query.OwnOnly {
282+
return b.database.GetSessionUsage(ctx, resolvedID, true)
283+
}
284+
return service.SessionUsageWithSubagents(
285+
ctx, b.database, resolvedID, true)
286+
}
287+
288+
u, err := load()
269289
if err != nil {
270290
return nil, tokenUseExitErr,
271291
fmt.Errorf("querying session usage: %w", err)
272292
}
273293
if u == nil {
274-
fmt.Fprintf(os.Stderr, "session not found: %s\n", sessionID)
294+
fmt.Fprintf(os.Stderr, "session not found: %s\n", query.SessionID)
275295
return nil, tokenUseExitNotFound, nil
276296
}
277297
if len(u.UnpricedModels) > 0 && !b.offline {
@@ -283,9 +303,7 @@ func (b localArchiveQueryBackend) SessionUsage(
283303
fmt.Fprintf(os.Stderr,
284304
"warning: pricing refresh failed: %v\n", refErr)
285305
} else if refreshed {
286-
if u2, e := b.database.GetSessionUsage(
287-
ctx, resolvedID, true,
288-
); e == nil && u2 != nil {
306+
if u2, e := load(); e == nil && u2 != nil {
289307
u = u2
290308
}
291309
}
@@ -301,6 +319,33 @@ func (b localArchiveQueryBackend) SessionUsage(
301319
}, usageExitCode(u), nil
302320
}
303321

322+
// syncSubagentTranscripts refreshes the subagent transcripts that live
323+
// beside a session's own file, so a session that just finished reports its
324+
// subagents' spend instead of whatever was last ingested. SyncSingleSession
325+
// only knows about the named session's file; the children are separate
326+
// transcripts under <session>/subagents/ that may not be in the archive
327+
// yet, so they are synced by path. Sync failures are non-fatal: the query
328+
// still runs against whatever is already stored.
329+
func syncSubagentTranscripts(
330+
ctx context.Context,
331+
engine *sync.Engine,
332+
database *db.DB,
333+
sessionID string,
334+
) {
335+
sourcePath := database.GetSessionFilePath(sessionID)
336+
if sourcePath == "" {
337+
sourcePath = engine.FindSourceFile(sessionID)
338+
}
339+
paths := parser.ClaudeSubagentTranscriptPaths(sourcePath)
340+
if len(paths) == 0 {
341+
return
342+
}
343+
if syncErr := engine.SyncPathsContext(ctx, paths); syncErr != nil {
344+
fmt.Fprintf(os.Stderr,
345+
"warning: subagent sync failed: %v\n", syncErr)
346+
}
347+
}
348+
304349
func closeArchiveQueryBackend(cleanup func()) {
305350
if cleanup != nil {
306351
cleanup()

cmd/agentsview/archive_query_backend_test.go

Lines changed: 85 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package main
33
import (
44
"context"
55
"net/http"
6+
"os"
67
"path/filepath"
78
"testing"
89
"time"
@@ -12,6 +13,8 @@ import (
1213
"go.kenn.io/agentsview/internal/config"
1314
"go.kenn.io/agentsview/internal/db"
1415
"go.kenn.io/agentsview/internal/dbtest"
16+
"go.kenn.io/agentsview/internal/parser"
17+
"go.kenn.io/agentsview/internal/testjsonl"
1518
)
1619

1720
func TestResolveArchiveQueryBackendNoSyncStartsNoSyncDaemon(t *testing.T) {
@@ -109,7 +112,8 @@ func TestLocalArchiveQuerySessionUsageNoSyncSkipsSingleSessionSync(
109112
}
110113
stderr := captureStderr(t, func() {
111114
out, exitCode, err := backend.SessionUsage(
112-
context.Background(), "codex:no-sync-usage",
115+
context.Background(),
116+
sessionUsageQuery{SessionID: "codex:no-sync-usage"},
113117
)
114118
require.NoError(t, err)
115119
require.NotNil(t, out)
@@ -119,3 +123,83 @@ func TestLocalArchiveQuerySessionUsageNoSyncSkipsSingleSessionSync(
119123
assert.NotContains(t, stderr, "warning: sync failed")
120124
assert.NotContains(t, stderr, "warning: pricing seed failed")
121125
}
126+
127+
// TestLocalSessionUsageRefreshesSubagentTranscripts covers the freshness
128+
// half of the subagent rollup: SyncSingleSession only knows about the named
129+
// session's file, so the backend must also ingest the agent-*.jsonl files
130+
// beside it. Without that, a session that just finished would report a
131+
// combined cost missing its most recent subagents.
132+
func TestLocalSessionUsageRefreshesSubagentTranscripts(t *testing.T) {
133+
dataDir := testDataDir(t)
134+
home := t.TempDir()
135+
t.Setenv("HOME", home)
136+
for _, def := range parser.Registry {
137+
if def.EnvVar != "" {
138+
t.Setenv(def.EnvVar,
139+
filepath.Join(home, "agent-dirs", string(def.Type)))
140+
}
141+
}
142+
claudeDir := t.TempDir()
143+
t.Setenv("CLAUDE_PROJECTS_DIR", claudeDir)
144+
145+
projDir := filepath.Join(claudeDir, "-home-proj")
146+
require.NoError(t, os.MkdirAll(
147+
filepath.Join(projDir, "parent-uuid", "subagents"), 0o755))
148+
parentPath := filepath.Join(projDir, "parent-uuid.jsonl")
149+
require.NoError(t, os.WriteFile(parentPath, []byte(
150+
testjsonl.NewSessionBuilder().
151+
AddClaudeUser("2026-05-20T10:00:00Z", "delegate this").
152+
AddClaudeAssistant("2026-05-20T10:00:05Z", "on it").
153+
String(),
154+
), 0o644))
155+
156+
dbPath := sessionsDBPath(dataDir)
157+
database := dbtest.OpenTestDBAt(t, dbPath)
158+
backend := localArchiveQueryBackend{
159+
cfg: config.Config{
160+
DBPath: dbPath,
161+
AgentDirs: map[parser.AgentType][]string{
162+
parser.AgentClaude: {claudeDir},
163+
},
164+
},
165+
database: database,
166+
offline: true,
167+
}
168+
ctx := context.Background()
169+
170+
// Ingest the parent, then write a subagent transcript the way Claude
171+
// Code does after the parent's own file was last synced.
172+
_, _, err := backend.SessionUsage(
173+
ctx, sessionUsageQuery{SessionID: "parent-uuid"})
174+
require.NoError(t, err)
175+
176+
require.NoError(t, os.WriteFile(
177+
filepath.Join(projDir, "parent-uuid", "subagents",
178+
"agent-worker1.jsonl"),
179+
[]byte(testjsonl.NewSessionBuilder().
180+
AddClaudeUserWithSessionID(
181+
"2026-05-20T10:01:00Z", "do the subtask", "parent-uuid").
182+
AddClaudeAssistant("2026-05-20T10:01:30Z", "subtask done").
183+
String()),
184+
0o644))
185+
186+
out, _, err := backend.SessionUsage(
187+
ctx, sessionUsageQuery{SessionID: "parent-uuid"})
188+
require.NoError(t, err)
189+
require.NotNil(t, out)
190+
assert.Equal(t, 1, out.SubagentCount,
191+
"the new subagent transcript must be ingested before the query")
192+
193+
child, err := database.GetSession(ctx, "agent-worker1")
194+
require.NoError(t, err)
195+
require.NotNil(t, child, "subagent session was not synced")
196+
require.NotNil(t, child.ParentSessionID)
197+
assert.Equal(t, "parent-uuid", *child.ParentSessionID)
198+
199+
// --own-only skips the subagent refresh and the combined view.
200+
own, _, err := backend.SessionUsage(
201+
ctx, sessionUsageQuery{SessionID: "parent-uuid", OwnOnly: true})
202+
require.NoError(t, err)
203+
require.NotNil(t, own)
204+
assert.Zero(t, own.SubagentCount)
205+
}

cmd/agentsview/session_test.go

Lines changed: 108 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1248,15 +1248,121 @@ func TestSessionUsage_ServerFlagUsesHTTP(t *testing.T) {
12481248
require.NoError(t, err)
12491249
require.NotNil(t, out)
12501250
assert.Equal(t, "/api/v1/sessions/remote-session/usage", reqs.UsagePath)
1251-
assert.Equal(t, "breakdown=true", reqs.UsageQuery,
1252-
"remote CLI must request full breakdown rows")
1251+
assert.Equal(t, "breakdown=true&subagents=true", reqs.UsageQuery,
1252+
"remote CLI must request full breakdown rows and subagent usage")
12531253
assert.Equal(t, tokenUseExitOK, code)
12541254
assert.Equal(t, "remote-session", out.SessionID)
12551255
assert.Equal(t, "remote-project", out.Project)
12561256
assert.Equal(t, 42, out.TotalOutputTokens)
12571257
assert.True(t, out.ServerRunning)
12581258
}
12591259

1260+
func TestSessionUsage_ServerFlagOwnOnlySkipsSubagents(t *testing.T) {
1261+
newAgentDataDir(t)
1262+
1263+
ts, reqs := newRemoteUsageServer(t, remoteUsageSpec{
1264+
canonicalID: "remote-session",
1265+
serverRunning: true,
1266+
})
1267+
1268+
cmd := sessionUsageCommand(t,
1269+
"session", "usage", "remote-session", "--server", ts.URL, "--own-only")
1270+
1271+
out, code, err := sessionUsageDataForCommand(cmd, "remote-session")
1272+
require.NoError(t, err)
1273+
require.NotNil(t, out)
1274+
assert.Equal(t, "breakdown=true", reqs.UsageQuery,
1275+
"--own-only must not ask the daemon for subagent usage")
1276+
assert.Equal(t, tokenUseExitOK, code)
1277+
}
1278+
1279+
// seedSubagentOnlyUsage seeds a parent session with no usage of its own and
1280+
// one subagent transcript that holds all the token data, which is the shape
1281+
// that used to make `session usage <parent>` exit 3 with an empty report.
1282+
func seedSubagentOnlyUsage(
1283+
t *testing.T, d *db.DB, parentID, childID string, outputTokens int,
1284+
) {
1285+
t.Helper()
1286+
require.NoError(t, d.UpsertSession(db.Session{
1287+
ID: parentID,
1288+
Project: "local-project",
1289+
Machine: "usage-host",
1290+
Agent: "claude",
1291+
MessageCount: 2,
1292+
UserMessageCount: 2,
1293+
}))
1294+
require.NoError(t, d.UpsertSession(db.Session{
1295+
ID: childID,
1296+
Project: "local-project",
1297+
Machine: "usage-host",
1298+
Agent: "claude",
1299+
MessageCount: 2,
1300+
ParentSessionID: &parentID,
1301+
RelationshipType: "subagent",
1302+
TotalOutputTokens: outputTokens,
1303+
HasTotalOutputTokens: true,
1304+
}))
1305+
}
1306+
1307+
func TestSessionUsage_LocalIncludesSubagentUsageByDefault(t *testing.T) {
1308+
dataDir := newAgentDataDir(t)
1309+
t.Setenv("AGENTSVIEW_NO_DAEMON", "1")
1310+
1311+
localDB := dbtest.OpenTestDBAt(t, sessionsDBPath(dataDir))
1312+
seedSubagentOnlyUsage(t, localDB, "claude:parent-only", "agent-child", 24)
1313+
1314+
cmd := sessionUsageCommand(t, "session", "usage", "claude:parent-only")
1315+
1316+
out, code, err := sessionUsageDataForCommand(cmd, "claude:parent-only")
1317+
require.NoError(t, err)
1318+
require.NotNil(t, out)
1319+
assert.Equal(t, tokenUseExitOK, code,
1320+
"a parent whose token data lives in subagents must not exit 3")
1321+
assert.Equal(t, "claude:parent-only", out.SessionID)
1322+
assert.Equal(t, 1, out.SubagentCount)
1323+
assert.Equal(t, 24, out.TotalOutputTokens)
1324+
assert.True(t, out.HasTokenData)
1325+
}
1326+
1327+
func TestSessionUsage_LocalOwnOnlyExcludesSubagentUsage(t *testing.T) {
1328+
dataDir := newAgentDataDir(t)
1329+
t.Setenv("AGENTSVIEW_NO_DAEMON", "1")
1330+
1331+
localDB := dbtest.OpenTestDBAt(t, sessionsDBPath(dataDir))
1332+
seedSubagentOnlyUsage(t, localDB, "claude:parent-only", "agent-child", 24)
1333+
1334+
cmd := sessionUsageCommand(t,
1335+
"session", "usage", "claude:parent-only", "--own-only")
1336+
1337+
out, code, err := sessionUsageDataForCommand(cmd, "claude:parent-only")
1338+
require.NoError(t, err)
1339+
require.NotNil(t, out)
1340+
assert.Equal(t, tokenUseExitNoTokenData, code,
1341+
"--own-only restores the pre-rollup empty report and exit code")
1342+
assert.Zero(t, out.SubagentCount)
1343+
assert.Zero(t, out.TotalOutputTokens)
1344+
assert.False(t, out.HasTokenData)
1345+
}
1346+
1347+
func TestSessionUsage_PGFlagIncludesSubagentUsage(t *testing.T) {
1348+
dataDir := newAgentDataDir(t)
1349+
t.Setenv("AGENTSVIEW_PG_URL", "postgres://example.test/agentsview")
1350+
1351+
pgDB := dbtest.OpenTestDBAt(t, filepath.Join(dataDir, "pg.db"))
1352+
seedSubagentOnlyUsage(t, pgDB, "claude:pg-parent", "agent-pg-child", 42)
1353+
1354+
stubPGReadStore(t, pgDB)
1355+
1356+
cmd := sessionUsageCommand(t, "session", "usage", "claude:pg-parent", "--pg")
1357+
1358+
out, code, err := sessionUsageDataForCommand(cmd, "claude:pg-parent")
1359+
require.NoError(t, err)
1360+
require.NotNil(t, out)
1361+
assert.Equal(t, tokenUseExitOK, code)
1362+
assert.Equal(t, 1, out.SubagentCount)
1363+
assert.Equal(t, 42, out.TotalOutputTokens)
1364+
}
1365+
12601366
func TestSessionUsage_UsesDiscoveredDaemon(t *testing.T) {
12611367
dataDir := newAgentDataDir(t)
12621368

0 commit comments

Comments
 (0)