Files
wehub-resource-sync f99010fae1
Desktop Artifacts / Desktop Build (Linux) (push) Waiting to run
Desktop Artifacts / Desktop Build (Windows) (push) Waiting to run
Desktop Artifacts / Desktop Build (Linux (arm64)) (push) Waiting to run
Desktop Artifacts (macOS) / Desktop Build (macOS (aarch64)) (push) Waiting to run
Desktop Artifacts (macOS) / Desktop Build (macOS (x86_64)) (push) Waiting to run
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
chore: import upstream snapshot with attribution
2026-07-13 12:30:36 +08:00

390 lines
18 KiB
Go

// ABOUTME: Tests for DAG fork detection in Claude JSONL session files.
// ABOUTME: Validates linear, large-gap fork, small-gap retry, and backward compat scenarios.
package parser
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.kenn.io/agentsview/internal/testjsonl"
)
func parseTestContent(t *testing.T, name, content string, expectedLen int) []ParseResult {
t.Helper()
path := createTestFile(t, name, content)
results, err := parseClaudeSession(path, "proj", "local")
require.NoError(t, err, "ParseClaudeSession")
require.Len(t, results, expectedLen)
return results
}
func formatTime(ts time.Time) string {
return ts.Format(time.RFC3339)
}
func TestForkDetection_LinearSession(t *testing.T) {
// Linear chain: a -> b -> c -> d, all with uuid/parentUuid.
// Should return 1 result with 4 messages.
content := testjsonl.NewSessionBuilder().
AddClaudeUserWithUUID("2024-01-01T10:00:00Z", "hello", "a", "").
AddClaudeAssistantWithUUID("2024-01-01T10:00:01Z", "hi there", "b", "a").
AddClaudeUserWithUUID("2024-01-01T10:00:02Z", "next question", "c", "b").
AddClaudeAssistantWithUUID("2024-01-01T10:00:03Z", "answer", "d", "c").
String()
results := parseTestContent(t, "linear.jsonl", content, 1)
assertSessionMeta(t, &results[0].Session, "linear", "proj", AgentClaude)
assertMessageCount(t, len(results[0].Messages), 4)
}
func TestForkDetection_LargeGapFork(t *testing.T) {
// Main branch: a->b->c->d->e->f->g->h (4+ user turns after fork)
// Fork from b: i->j
// Fork point is at node b, which has children c (first) and i.
// First branch (c side) has user turns at c, e, g = 3 user turns,
// but we need >3, so add more.
//
// Let's make:
// a(user) -> b(asst) -> c(user) -> d(asst) -> e(user) -> f(asst)
// -> g(user) -> h(asst)
// -> i(user) -> j(asst) [fork from b]
//
// User turns on first branch after fork point b: c, e, g = 3,
// need >3 so add one more pair.
// a(user) -> b(asst) -> c(user) -> d(asst) -> e(user) -> f(asst)
// -> g(user) -> h(asst) -> k(user) -> l(asst)
// -> i(user) -> j(asst)
//
// User turns on first branch from c onward: c, e, g, k = 4 > 3 = large gap.
content := testjsonl.NewSessionBuilder().
AddClaudeUserWithUUID("2024-01-01T10:00:00Z", "hello", "a", "").
AddClaudeAssistantWithUUID("2024-01-01T10:00:01Z", "hi", "b", "a").
AddClaudeUserWithUUID("2024-01-01T10:00:02Z", "q1", "c", "b").
AddClaudeAssistantWithUUID("2024-01-01T10:00:03Z", "a1", "d", "c").
AddClaudeUserWithUUID("2024-01-01T10:00:04Z", "q2", "e", "d").
AddClaudeAssistantWithUUID("2024-01-01T10:00:05Z", "a2", "f", "e").
AddClaudeUserWithUUID("2024-01-01T10:00:06Z", "q3", "g", "f").
AddClaudeAssistantWithUUID("2024-01-01T10:00:07Z", "a3", "h", "g").
AddClaudeUserWithUUID("2024-01-01T10:00:08Z", "q4", "k", "h").
AddClaudeAssistantWithUUID("2024-01-01T10:00:09Z", "a4", "l", "k").
// Fork branch from b
AddClaudeUserWithUUID("2024-01-01T10:01:00Z", "fork q1", "i", "b").
AddClaudeAssistantWithUUID("2024-01-01T10:01:01Z", "fork a1", "j", "i").
String()
results := parseTestContent(t, "fork.jsonl", content, 2)
// Main session: all entries on first branch (a,b,c,d,e,f,g,h,k,l)
main := results[0]
assertSessionMeta(t, &main.Session, "fork", "proj", AgentClaude)
assertMessageCount(t, len(main.Messages), 10)
assert.Empty(t, main.Session.ParentSessionID, "main ParentSessionID")
// Fork session: entries on fork branch (i,j)
fork := results[1]
assert.Equal(t, "fork-i", fork.Session.ID, "fork session ID")
assertMessageCount(t, len(fork.Messages), 2)
assert.Equal(t, "fork", fork.Session.ParentSessionID, "fork ParentSessionID")
assert.Equal(t, RelFork, fork.Session.RelationshipType, "fork RelationshipType")
assert.Equal(t, "fork q1", fork.Session.FirstMessage, "fork FirstMessage")
}
func TestForkDetection_SmallGapRetry(t *testing.T) {
// Main: a(user)->b(asst)->c(user)->d(asst) (1 user turn after fork = small gap)
// Retry from b: e(user)->f(asst)
// First branch from b has c,d — only 1 user turn (c). ≤3 = small gap.
// Should follow LAST child (e), so result is: a,b,e,f
content := testjsonl.NewSessionBuilder().
AddClaudeUserWithUUID("2024-01-01T10:00:00Z", "hello", "a", "").
AddClaudeAssistantWithUUID("2024-01-01T10:00:01Z", "hi", "b", "a").
AddClaudeUserWithUUID("2024-01-01T10:00:02Z", "first try", "c", "b").
AddClaudeAssistantWithUUID("2024-01-01T10:00:03Z", "first answer", "d", "c").
// Retry from b (later in file)
AddClaudeUserWithUUID("2024-01-01T10:01:00Z", "retry", "e", "b").
AddClaudeAssistantWithUUID("2024-01-01T10:01:01Z", "retry answer", "f", "e").
String()
results := parseTestContent(t, "retry.jsonl", content, 1)
// Latest branch wins: a, b, e, f
assertMessageCount(t, len(results[0].Messages), 4)
assertMessage(t, results[0].Messages[0], RoleUser, "hello")
assertMessage(t, results[0].Messages[1], RoleAssistant, "hi")
assertMessage(t, results[0].Messages[2], RoleUser, "retry")
assertMessage(t, results[0].Messages[3], RoleAssistant, "retry answer")
}
func TestForkDetection_NoUUIDs(t *testing.T) {
// Entries without uuid fields — should work as before, 1 result.
content := testjsonl.NewSessionBuilder().
AddClaudeUser("2024-01-01T10:00:00Z", "hello").
AddClaudeAssistant("2024-01-01T10:00:01Z", "hi").
AddClaudeUser("2024-01-01T10:00:02Z", "bye").
AddClaudeAssistant("2024-01-01T10:00:03Z", "goodbye").
String()
results := parseTestContent(t, "nouuid.jsonl", content, 1)
assertMessageCount(t, len(results[0].Messages), 4)
assertMessage(t, results[0].Messages[0], RoleUser, "hello")
}
func TestForkDetection_MixedUUIDs(t *testing.T) {
// Some entries have uuid, some don't — fall back to linear.
content := testjsonl.NewSessionBuilder().
AddClaudeUser("2024-01-01T10:00:00Z", "no uuid").
AddClaudeAssistantWithUUID("2024-01-01T10:00:01Z", "has uuid", "x", "").
AddClaudeUser("2024-01-01T10:00:02Z", "no uuid again").
String()
results := parseTestContent(t, "mixed.jsonl", content, 1)
assertMessageCount(t, len(results[0].Messages), 3)
}
func TestForkDetection_NestedFork(t *testing.T) {
// Main: a->b->c->d->e->f->g->h->k->l (5 user turns)
// Fork from b: m->n->o->p->q->r->s->t->u->v (5 user turns on fork branch)
// Nested fork from n: w->x (fork within the fork branch)
// Fork from b has 5 user turns on first child path (m,o,q,s,u) > 3 = large gap
// Nested fork from n has 4 user turns on first child path (o,q,s,u) > 3 = large gap
content := testjsonl.NewSessionBuilder().
AddClaudeUserWithUUID("2024-01-01T10:00:00Z", "start", "a", "").
AddClaudeAssistantWithUUID("2024-01-01T10:00:01Z", "ok", "b", "a").
// Main branch from b
AddClaudeUserWithUUID("2024-01-01T10:00:02Z", "main1", "c", "b").
AddClaudeAssistantWithUUID("2024-01-01T10:00:03Z", "m-ok1", "d", "c").
AddClaudeUserWithUUID("2024-01-01T10:00:04Z", "main2", "e", "d").
AddClaudeAssistantWithUUID("2024-01-01T10:00:05Z", "m-ok2", "f", "e").
AddClaudeUserWithUUID("2024-01-01T10:00:06Z", "main3", "g", "f").
AddClaudeAssistantWithUUID("2024-01-01T10:00:07Z", "m-ok3", "h", "g").
AddClaudeUserWithUUID("2024-01-01T10:00:08Z", "main4", "k", "h").
AddClaudeAssistantWithUUID("2024-01-01T10:00:09Z", "m-ok4", "l", "k").
// Fork branch from b
AddClaudeUserWithUUID("2024-01-01T10:01:00Z", "fork1", "m", "b").
AddClaudeAssistantWithUUID("2024-01-01T10:01:01Z", "f-ok1", "n", "m").
AddClaudeUserWithUUID("2024-01-01T10:01:02Z", "fork2", "o", "n").
AddClaudeAssistantWithUUID("2024-01-01T10:01:03Z", "f-ok2", "p", "o").
AddClaudeUserWithUUID("2024-01-01T10:01:04Z", "fork3", "q", "p").
AddClaudeAssistantWithUUID("2024-01-01T10:01:05Z", "f-ok3", "r", "q").
AddClaudeUserWithUUID("2024-01-01T10:01:06Z", "fork4", "s", "r").
AddClaudeAssistantWithUUID("2024-01-01T10:01:07Z", "f-ok4", "tt", "s").
AddClaudeUserWithUUID("2024-01-01T10:01:08Z", "fork5", "u", "tt").
AddClaudeAssistantWithUUID("2024-01-01T10:01:09Z", "f-ok5", "v", "u").
// Nested fork from n (within the fork branch)
AddClaudeUserWithUUID("2024-01-01T10:02:00Z", "nested", "w", "n").
AddClaudeAssistantWithUUID("2024-01-01T10:02:01Z", "n-ok", "x", "w").
String()
// Expect 3 results: main, nested fork from n, fork from b
// (depth-first: nested fork discovered during recursive walk of b's fork)
results := parseTestContent(t, "nested-fork.jsonl", content, 3)
// Main: a,b,c,d,e,f,g,h,k,l = 10 messages
assertMessageCount(t, len(results[0].Messages), 10)
// Nested fork from n (discovered first during depth-first walk): w,x = 2 messages
nested := results[1]
assert.Equal(t, "nested-fork-w", nested.Session.ID, "nested ID")
assertMessageCount(t, len(nested.Messages), 2)
assert.Equal(t, RelFork, nested.Session.RelationshipType, "nested RelationshipType")
// Nested fork's parent should be the fork branch it split
// from, not the root session.
assert.Equal(t, "nested-fork-m", nested.Session.ParentSessionID, "nested ParentSessionID")
// Fork from b: m,n,o,p,q,r,s,tt,u,v = 10 messages
fork := results[2]
assert.Equal(t, "nested-fork-m", fork.Session.ID, "fork ID")
assertMessageCount(t, len(fork.Messages), 10)
assert.Equal(t, RelFork, fork.Session.RelationshipType, "fork RelationshipType")
// Fork from b's parent should be the root session.
assert.Equal(t, "nested-fork", fork.Session.ParentSessionID, "fork ParentSessionID")
}
func TestForkDetection_MultipleRoots(t *testing.T) {
// Two entries with empty parentUuid = two roots.
// A well-formed DAG has exactly one root, so multiple roots
// should fall back to linear parsing, returning all messages.
content := testjsonl.NewSessionBuilder().
AddClaudeUserWithUUID("2024-01-01T10:00:00Z", "root one", "a", "").
AddClaudeAssistantWithUUID("2024-01-01T10:00:01Z", "reply one", "b", "a").
AddClaudeUserWithUUID("2024-01-01T10:00:02Z", "root two", "c", "").
AddClaudeAssistantWithUUID("2024-01-01T10:00:03Z", "reply two", "d", "c").
String()
results := parseTestContent(t, "multi-root.jsonl", content, 1)
// All 4 messages must be present.
assertMessageCount(t, len(results[0].Messages), 4)
assertMessage(t, results[0].Messages[0], RoleUser, "root one")
assertMessage(t, results[0].Messages[1], RoleAssistant, "reply one")
assertMessage(t, results[0].Messages[2], RoleUser, "root two")
assertMessage(t, results[0].Messages[3], RoleAssistant, "reply two")
}
func TestForkDetection_DisconnectedParent(t *testing.T) {
// Entry "c" has parentUuid "nonexistent" which doesn't match
// any entry's uuid. This means the DAG is disconnected, so
// we should fall back to linear parsing.
content := testjsonl.NewSessionBuilder().
AddClaudeUserWithUUID("2024-01-01T10:00:00Z", "hello", "a", "").
AddClaudeAssistantWithUUID("2024-01-01T10:00:01Z", "hi", "b", "a").
AddClaudeUserWithUUID("2024-01-01T10:00:02Z", "orphan", "c", "nonexistent").
AddClaudeAssistantWithUUID("2024-01-01T10:00:03Z", "orphan reply", "d", "c").
String()
results := parseTestContent(t, "disconnected.jsonl", content, 1)
// All 4 messages must be present.
assertMessageCount(t, len(results[0].Messages), 4)
assertMessage(t, results[0].Messages[0], RoleUser, "hello")
assertMessage(t, results[0].Messages[1], RoleAssistant, "hi")
assertMessage(t, results[0].Messages[2], RoleUser, "orphan")
assertMessage(t, results[0].Messages[3], RoleAssistant, "orphan reply")
}
func TestSessionBoundsIncludeNonMessageEvents(t *testing.T) {
// A trailing queue-operation event has a later timestamp
// than any user/assistant message. Session endedAt should
// still reflect that later timestamp.
queueLine := `{"type":"queue-operation","operation":"enqueue",` +
`"timestamp":"2024-01-01T11:00:00Z","content":"{}"}`
content := testjsonl.NewSessionBuilder().
AddClaudeUser("2024-01-01T10:00:00Z", "hello").
AddClaudeAssistant("2024-01-01T10:00:01Z", "hi").
AddRaw(queueLine).
String()
results := parseTestContent(t, "queue-ts.jsonl", content, 1)
sess := results[0].Session
assert.Equal(t, "2024-01-01T11:00:00Z", formatTime(sess.EndedAt), "EndedAt")
}
func TestSessionBoundsStartedAtFromLeadingEvent(t *testing.T) {
// A leading non-message event has an earlier timestamp
// than the first user message. StartedAt should reflect it.
earlyLine := `{"type":"queue-operation","operation":"enqueue",` +
`"timestamp":"2024-01-01T09:00:00Z","content":"{}"}`
content := testjsonl.NewSessionBuilder().
AddRaw(earlyLine).
AddClaudeUser("2024-01-01T10:00:00Z", "hello").
AddClaudeAssistant("2024-01-01T10:00:01Z", "hi").
String()
results := parseTestContent(t, "early-queue.jsonl", content, 1)
sess := results[0].Session
assert.Equal(t, "2024-01-01T09:00:00Z", formatTime(sess.StartedAt), "StartedAt")
}
func TestForkDetection_NestedForkCountsFullSubtree(t *testing.T) {
// Regression test: when the first child at a fork point
// itself contains nested forks early in its chain, the
// old first-child-only countUserTurns would see only 1
// user turn (following first children that dead-end
// quickly) and treat the entire large branch as a small
// retry, discarding it.
//
// DAG: root(a) -> b (fork point)
// First child: c -> d (fork) -> e -> f -> g -> h -> i -> j
// \-> d2 (retry, 1 entry)
// Second child: z (1 entry, the "retry")
//
// The first child subtree has 5 user turns total (c,e,g,i
// plus d2). With first-child-only traversal, the path
// c->d->d2 sees only 1 user turn (c is user, d is asst,
// d2 is user but d2 is the SECOND child not the first) --
// actually c->d->(first child of d's fork)=e gives more.
// Let's build a clearer case: the first child at the fork
// is a dead-end assistant reply, so first-child traversal
// stops after 0 user turns.
//
// DAG: root(a) -> b (fork)
// First child: c(user) -> d(asst, fork)
// d -> e(asst, dead-end first child)
// d -> f(user) -> g(asst) -> h(user) ->
// i(asst) -> j(user) -> k(asst)
// Second child: z(user, 1 msg)
//
// Old countUserTurns for c: c(user,1) -> d(asst) ->
// e(asst, no children) = 1 user turn <= 3 -> retry!
// New countUserTurns for c: 1+0+1+0+1+0+1+0 = 4 > 3
content := testjsonl.NewSessionBuilder().
AddClaudeUserWithUUID("2024-01-01T10:00:00Z", "start", "a", "").
AddClaudeAssistantWithUUID("2024-01-01T10:00:01Z", "ok", "b", "a").
// First child branch from b: large subtree
AddClaudeUserWithUUID("2024-01-01T10:00:02Z", "main1", "c", "b").
AddClaudeAssistantWithUUID("2024-01-01T10:00:03Z", "m-ok1", "d", "c").
// Nested fork at d: first child is a dead-end
AddClaudeAssistantWithUUID("2024-01-01T10:00:04Z", "dead-end", "e", "d").
// Second child of d's fork continues the real conversation
AddClaudeUserWithUUID("2024-01-01T10:00:05Z", "main2", "f", "d").
AddClaudeAssistantWithUUID("2024-01-01T10:00:06Z", "m-ok2", "g", "f").
AddClaudeUserWithUUID("2024-01-01T10:00:07Z", "main3", "h", "g").
AddClaudeAssistantWithUUID("2024-01-01T10:00:08Z", "m-ok3", "i", "h").
AddClaudeUserWithUUID("2024-01-01T10:00:09Z", "main4", "j", "i").
AddClaudeAssistantWithUUID("2024-01-01T10:00:10Z", "m-ok4", "k", "j").
// Second child of b's fork: trivial retry
AddClaudeUserWithUUID("2024-01-01T10:01:00Z", "retry", "z", "b").
String()
// The first child subtree has 4 user turns (c,f,h,j) > 3,
// so it should be treated as a large-gap fork. We expect
// 2 results: main path (a,b,c,d,f,g,h,i,j,k = 10 msgs)
// and the fork (z = 1 msg).
results := parseTestContent(t, "nested-fork-subtree.jsonl", content, 2)
// Main path should follow first child at b, then second
// child at d (the retry heuristic picks last child when
// first child has <= 3 user turns — here "e" is a dead
// end with 0 user turns so the nested fork follows "f").
main := results[0]
assert.GreaterOrEqual(t, main.Session.MessageCount, 8,
"main MessageCount (first child subtree should not be discarded)")
// The trivial "retry" branch should be the fork.
fork := results[1]
assertMessage(t, fork.Messages[0], RoleUser, "retry")
}
func TestSessionBoundsDAGMainWidenedNotFork(t *testing.T) {
// DAG session with a trailing queue-operation after all
// messages. Main session's EndedAt should be widened;
// fork session should use only its own message bounds.
queueLine := `{"type":"queue-operation","operation":"enqueue",` +
`"timestamp":"2024-01-01T12:00:00Z","content":"{}"}`
// Main: a->b->c->d->e->f->g->h->k->l (5 user turns)
// Fork from b: i->j
content := testjsonl.NewSessionBuilder().
AddClaudeUserWithUUID("2024-01-01T10:00:00Z", "hello", "a", "").
AddClaudeAssistantWithUUID("2024-01-01T10:00:01Z", "hi", "b", "a").
AddClaudeUserWithUUID("2024-01-01T10:00:02Z", "q1", "c", "b").
AddClaudeAssistantWithUUID("2024-01-01T10:00:03Z", "a1", "d", "c").
AddClaudeUserWithUUID("2024-01-01T10:00:04Z", "q2", "e", "d").
AddClaudeAssistantWithUUID("2024-01-01T10:00:05Z", "a2", "f", "e").
AddClaudeUserWithUUID("2024-01-01T10:00:06Z", "q3", "g", "f").
AddClaudeAssistantWithUUID("2024-01-01T10:00:07Z", "a3", "h", "g").
AddClaudeUserWithUUID("2024-01-01T10:00:08Z", "q4", "k", "h").
AddClaudeAssistantWithUUID("2024-01-01T10:00:09Z", "a4", "l", "k").
// Fork from b
AddClaudeUserWithUUID("2024-01-01T10:01:00Z", "fork", "i", "b").
AddClaudeAssistantWithUUID("2024-01-01T10:01:01Z", "fork-a", "j", "i").
AddRaw(queueLine).
String()
results := parseTestContent(t, "dag-queue.jsonl", content, 2)
// Main session EndedAt should be widened to queue timestamp.
assert.Equal(t, "2024-01-01T12:00:00Z", formatTime(results[0].Session.EndedAt), "main EndedAt")
// Fork session EndedAt should NOT be widened.
assert.Equal(t, "2024-01-01T10:01:01Z", formatTime(results[1].Session.EndedAt), "fork EndedAt")
}