Repository navigation
feat: archive subagent transcripts as their own sessions (#31) #71
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 3 commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
0f480a3
feat: sessions.parent_session_id, with a backfill for ids that carrie…
detour1999 6c098a9
feat: archive subagent transcripts from ~/.claude as their own sessions
detour1999 4868423
feat: subagent sessions are hidden from listings, never unreachable
detour1999 12d2f9d
fix: size the SESSION ID column to its ids, and show SUBS in the TUI …
detour1999 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,54 @@ | ||
| // ABOUTME: Tests the list-sessions subagent flags and the SUBS column rendering. | ||
| // ABOUTME: The default hides subagent rows; the count is what keeps that honest. | ||
|
|
||
| package main | ||
|
|
||
| import ( | ||
| "testing" | ||
|
|
||
| "github.com/2389-research/ccvault/internal/db" | ||
| ) | ||
|
|
||
| func TestSubagentScopeFromFlags(t *testing.T) { | ||
| cases := []struct { | ||
| name string | ||
| include bool | ||
| of string | ||
| wantScope db.SubagentScope | ||
| wantID string | ||
| }{ | ||
| {name: "default hides them", wantScope: db.SubagentsHidden}, | ||
| {name: "include flattens", include: true, wantScope: db.SubagentsIncluded}, | ||
| {name: "subagents-of narrows", of: "parent-a", wantScope: db.SubagentsOf, wantID: "parent-a"}, | ||
| { | ||
| // Asking for one parent's children is more specific than asking | ||
| // for everything, so it wins. | ||
| name: "subagents-of wins over include", | ||
| include: true, | ||
| of: "parent-a", | ||
| wantScope: db.SubagentsOf, | ||
| wantID: "parent-a", | ||
| }, | ||
| } | ||
|
|
||
| for _, tc := range cases { | ||
| t.Run(tc.name, func(t *testing.T) { | ||
| scope, id := subagentScopeFromFlags(tc.include, tc.of) | ||
| if scope != tc.wantScope { | ||
| t.Errorf("scope = %v, want %v", scope, tc.wantScope) | ||
| } | ||
| if id != tc.wantID { | ||
| t.Errorf("parent id = %q, want %q", id, tc.wantID) | ||
| } | ||
| }) | ||
| } | ||
| } | ||
|
|
||
| func TestFormatSubagentCount(t *testing.T) { | ||
| cases := map[int]string{0: "-", 1: "1", 72: "72"} | ||
| for in, want := range cases { | ||
| if got := formatSubagentCount(in); got != want { | ||
| t.Errorf("formatSubagentCount(%d) = %q, want %q", in, got, want) | ||
| } | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,91 @@ | ||
| // ABOUTME: Tests that analytics counts subagent sessions rather than filtering them. | ||
| // ABOUTME: The hidden-by-default listing rule is a listing rule, not an accounting rule. | ||
|
|
||
| package analytics | ||
|
|
||
| import ( | ||
| "testing" | ||
| "time" | ||
|
|
||
| "github.com/2389-research/ccvault/internal/db" | ||
| ) | ||
|
|
||
| // TestAnalyticsCountsSubagentSessions is the loud half of this change: a | ||
| // subagent transcript's tokens are additive, not duplicated — parent | ||
| // transcripts contain no sidechain lines at all — so counting them raises the | ||
| // totals. On the author's archive the 63 parents with ingested subagents hold | ||
| // 2,905,646 tokens and their subagents another 832,723, about 29% more. | ||
| func TestAnalyticsCountsSubagentSessions(t *testing.T) { | ||
| database, err := db.Open(t.TempDir()) | ||
| if err != nil { | ||
| t.Fatalf("open db: %v", err) | ||
| } | ||
| t.Cleanup(func() { _ = database.Close() }) | ||
|
|
||
| now := time.Now().UTC() | ||
| seedSession(t, database, now, "/tmp/proj-a", "proj-a", "parent-a", | ||
| "claude-opus-4", "claude-code", 100, 50, 0) | ||
| seedSubagentSession(t, database, now.Add(time.Minute), "/tmp/proj-a", "claude-code:parent-a:agent-a1", | ||
| "parent-a", 1000, 500) | ||
|
|
||
| // The summary the TUI and MCP read is driven by these totals. | ||
| count, turns, tokens, err := database.GetSessionStats() | ||
| if err != nil { | ||
| t.Fatalf("session stats: %v", err) | ||
| } | ||
| if count != 2 { | ||
| t.Errorf("session count = %d, want 2 — analytics must count subagent sessions", count) | ||
| } | ||
| if turns != 4 { | ||
| t.Errorf("turn count = %d, want 4", turns) | ||
| } | ||
| if tokens != 1650 { | ||
| t.Errorf("token total = %d, want 1650 (150 parent + 1500 subagent)", tokens) | ||
| } | ||
|
|
||
| // And the parquet export, which backs the DuckDB analytics views. | ||
| cacheDir := t.TempDir() | ||
| if err := NewExporter(database, cacheDir).Export(); err != nil { | ||
| t.Fatalf("export: %v", err) | ||
| } | ||
| analyzer, err := NewAnalyzer(cacheDir) | ||
| if err != nil { | ||
| t.Fatalf("new analyzer: %v", err) | ||
| } | ||
| t.Cleanup(func() { _ = analyzer.Close() }) | ||
|
|
||
| summary, err := analyzer.GetSummary() | ||
| if err != nil { | ||
| t.Fatalf("get summary: %v", err) | ||
| } | ||
| if summary.TotalSessions != 2 { | ||
| t.Errorf("parquet TotalSessions = %d, want 2", summary.TotalSessions) | ||
| } | ||
| if summary.TotalTokens != 1650 { | ||
| t.Errorf("parquet TotalTokens = %d, want 1650", summary.TotalTokens) | ||
| } | ||
| } | ||
|
|
||
| // seedSubagentSession inserts a session row linked to a parent, in the shape | ||
| // the claude-code adapter now mints. | ||
| func seedSubagentSession(t *testing.T, d *db.DB, when time.Time, projectPath, sessionID, parentID string, inputTok, outputTok int64) { | ||
| t.Helper() | ||
|
|
||
| proj, err := d.GetProjectByPath(projectPath) | ||
| if err != nil { | ||
| t.Fatalf("get project: %v", err) | ||
| } | ||
| if proj == nil { | ||
| t.Fatalf("project %s must exist before its subagent session", projectPath) | ||
| } | ||
|
|
||
| _, err = d.Exec(`INSERT INTO sessions | ||
| (id, project_id, model, git_branch, started_at, ended_at, turn_count, input_tokens, output_tokens, | ||
| cache_read_tokens, cache_write_tokens, source_file, source, parent_session_id) | ||
| VALUES (?, ?, 'claude-opus-4', 'main', ?, ?, 2, ?, ?, 0, 0, ?, 'claude-code', ?)`, | ||
| sessionID, proj.ID, when, when, inputTok, outputTok, | ||
| "/tmp/parent-a/subagents/agent-a1.jsonl", parentID) | ||
| if err != nil { | ||
| t.Fatalf("insert subagent session: %v", err) | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,52 @@ | ||
| -- ABOUTME: Adds sessions.parent_session_id so a subagent transcript can be its own row. | ||
| -- ABOUTME: Backfills the link for subagent rows that only ever carried it inside their composite id. | ||
|
|
||
| -- A subagent transcript (<project>/<session-uuid>/subagents/agent-*.jsonl) is | ||
| -- its own session row, identified by a minted composite id: | ||
| -- | ||
| -- <source>:<parent-uuid>:agent-<agentId> | ||
| -- | ||
| -- The relationship lives in this column rather than only inside that string, | ||
| -- so listings can filter on it and a parent can count its children. NULL means | ||
| -- top-level, which is what every pre-existing row is. | ||
| ALTER TABLE sessions ADD COLUMN parent_session_id TEXT REFERENCES sessions(id); | ||
|
|
||
| -- Both directions are hot: "is this row top-level?" on every default listing, | ||
| -- and "how many children does this parent have?" for subagent_count. | ||
| CREATE INDEX IF NOT EXISTS idx_sessions_parent ON sessions(parent_session_id) | ||
| WHERE parent_session_id IS NOT NULL; | ||
|
|
||
| -- Backfill. The nanoclaw adapter has always minted composite ids for the | ||
| -- sidechain files it ingests (133 rows on the author's archive) and has always | ||
| -- computed a parent_session_id into its Metadata — but there was no column, so | ||
| -- sync dropped it and every one of those rows was orphaned: reachable by id, | ||
| -- unlinked to its parent. The id is the only surviving record of the link, and | ||
| -- it is enough: the parent id is the composite id minus its last ':' segment. | ||
| -- | ||
| -- substr/rtrim/replace is the SQLite spelling of "index of the last colon": | ||
| -- replace(id, ':', '') is the id with colons removed, so rtrim(id, <that>) | ||
| -- strips trailing non-colon characters and leaves a string ending at the last | ||
| -- colon. Its length is that colon's position. | ||
| -- | ||
| -- Scope, deliberately narrow: | ||
| -- * source_file under a subagents/ directory — the on-disk fact that makes a | ||
| -- row a sidechain, independent of how its id happens to be spelled. | ||
| -- * id of the <prefix>:<parent>:agent-<id> shape, so a two-segment id never | ||
| -- gets truncated to its bare prefix. | ||
| -- * parent_session_id IS NULL, so this can only ever fill a blank. | ||
| -- * NOT 'claude-code:%' — claude-code subagent rows are minted by the code | ||
| -- that ships with this migration and always write the column directly, and | ||
| -- their parent id is the bare uuid (no source prefix), so the | ||
| -- strip-last-segment rule would produce a parent that does not exist. | ||
| -- No such row can predate this migration: the scanner skipped them all. | ||
| -- | ||
| -- Rows whose computed parent is not itself in the archive still get the link. | ||
| -- It is the truth about where the transcript came from, and the default | ||
| -- listing deliberately keeps showing a subagent whose parent is absent so that | ||
| -- hiding never turns into losing. | ||
| UPDATE sessions | ||
| SET parent_session_id = substr(id, 1, length(rtrim(id, replace(id, ':', ''))) - 1) | ||
| WHERE parent_session_id IS NULL | ||
| AND source_file LIKE '%/subagents/%' | ||
| AND id LIKE '%:%:agent-%' | ||
| AND id NOT LIKE 'claude-code:%'; |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.