diff --git a/internal/claude/claude.go b/internal/claude/claude.go index 6c0b55b..0ca422b 100644 --- a/internal/claude/claude.go +++ b/internal/claude/claude.go @@ -17,8 +17,9 @@ // // Ignored by default: tool_use, successful tool_result, thinking, queue/mode/ // permission bookkeeping, file-history snapshots, last-prompt, subagent -// (isSidechain) and injected (isMeta) entries, subagent files under -// */subagents/*, and .claude/transcripts/ses_*.jsonl (v1). +// (isSidechain) and injected (isMeta) entries, user records the harness wrote +// itself (see harnessWrappers), subagent files under */subagents/*, and +// .claude/transcripts/ses_*.jsonl (v1). package claude import ( @@ -289,6 +290,9 @@ func readThread(fi fileInfo) (session.Thread, error) { if role == "" { continue } + if role == session.RoleUser && harnessOnly(text) { + continue // a user record the harness wrote itself, not a turn + } entries = append(entries, session.Entry{Kind: session.KindMessage, Role: role, Text: text, Time: ts}) } @@ -367,6 +371,66 @@ func joinText(blocks []claudeBlock, raw json.RawMessage) string { return strings.Join(parts, "\n") } +// harnessWrappers are the XML wrappers Claude Code writes into type:"user" +// records that are neither the user's words nor the user's act: a background +// task's report, what a local command printed, its caveat, and an injected +// reminder. The wrappers that record something the user did — the +// slash-command trio, a ! bash line — stay on the timeline, however noisy, +// because their text is the request. +// +// Only these names, and only spelled in full: markup a user pasted (
, +// ) is a turn they meant to send, and is +// not . +var harnessWrappers = map[string]bool{ + "local-command-caveat": true, "local-command-stdout": true, + "system-reminder": true, "task-notification": true, +} + +// harnessOnly reports whether a user message is nothing but harness wrappers. +// One record can carry several in a row. A wrapper with no closing tag is not +// one the harness wrote, so the record is kept. +// +// Nothing but: a record that still says something once its wrappers are read +// off is kept whole, text and wrappers together. Every injected record in the +// corpus this was measured against is wrapper-only, so trimming a mixed one +// would buy no quiet while risking the words of a user whose message happens to +// open with or a pasted reminder. +func harnessOnly(text string) bool { + rest, seen := strings.TrimSpace(text), false + for { + name, ok := openTag(rest) + if !ok || !harnessWrappers[name] { + return seen && rest == "" + } + seen = true + _, end := session.IndexFold(rest, "") + if end < 0 { + return false + } + rest = strings.TrimSpace(rest[end:]) + } +} + +// openTag returns the lowercased name of a bare element opening s. +// Anything else — attributes, a closing tag, a name with punctuation — is not +// one of the wrappers, which the harness always writes bare. +func openTag(s string) (string, bool) { + if !strings.HasPrefix(s, "<") { + return "", false + } + end := strings.IndexByte(s, '>') + if end < 2 { + return "", false + } + name := s[1:end] + for _, r := range name { + if r != '-' && r != '_' && !('0' <= r && r <= '9') && !('a' <= r && r <= 'z') && !('A' <= r && r <= 'Z') { + return "", false + } + } + return strings.ToLower(name), true +} + func normalizeRole(role string) string { switch role { case session.RoleUser, session.RoleAssistant: diff --git a/internal/claude/claude_test.go b/internal/claude/claude_test.go index 68ce209..27b8a3e 100644 --- a/internal/claude/claude_test.go +++ b/internal/claude/claude_test.go @@ -126,6 +126,79 @@ func TestCwdIsWhereTheSessionStarted(t *testing.T) { } } +// The harness files records of its own under role user: a background task's +// report, what a local command printed, reminders. Every record below is +// synthetic. The ones that keep their text are the reason the rule is a name +// list rather than "starts with a tag" — and why a slash command and a ! bash +// line, which are things the user did, stay on the timeline. +const harnessTranscript = `{"type":"user","sessionId":"sess-h","cwd":"/home/u/src/catchup","timestamp":"2026-06-26T10:00:00Z","message":{"role":"user","content":"land the parser fix"}} +{"type":"user","sessionId":"sess-h","timestamp":"2026-06-26T10:00:10Z","message":{"role":"user","content":"t1completedthe agent reported back"}} +{"type":"user","sessionId":"sess-h","timestamp":"2026-06-26T10:00:20Z","message":{"role":"user","content":"/review\nreview\nthe parser branch"}} +{"type":"user","sessionId":"sess-h","timestamp":"2026-06-26T10:00:30Z","message":{"role":"user","content":"branch is clean"}} +{"type":"user","sessionId":"sess-h","timestamp":"2026-06-26T10:00:40Z","message":{"role":"user","content":"ls internal"}} +{"type":"user","sessionId":"sess-h","timestamp":"2026-06-26T10:00:50Z","message":{"role":"user","content":"claude\nsession"}} +{"type":"user","sessionId":"sess-h","timestamp":"2026-06-26T10:01:00Z","message":{"role":"user","content":"\n\n mind the budget"}} +{"type":"user","sessionId":"sess-h","timestamp":"2026-06-26T10:01:10Z","message":{"role":"user","content":[{"type":"text","text":"the command was run locally"}]}} +{"type":"user","sessionId":"sess-h","timestamp":"2026-06-26T10:01:20Z","message":{"role":"user","content":"cut short"}} +{"type":"user","sessionId":"sess-h","timestamp":"2026-06-26T10:01:30Z","message":{"role":"user","content":"be brief\n\nwhy did the build fail?"}} +{"type":"user","sessionId":"sess-h","timestamp":"2026-06-26T10:01:40Z","message":{"role":"user","content":[{"type":"text","text":"/mine is my own tag, keep it"}]}} +{"type":"user","sessionId":"sess-h","timestamp":"2026-06-26T10:01:50Z","message":{"role":"user","content":"
hello
why does this not render?"}} +{"type":"user","sessionId":"sess-h","timestamp":"2026-06-26T10:02:00Z","message":{"role":"user","content":"my own notes"}} +{"type":"user","sessionId":"sess-h","timestamp":"2026-06-26T10:02:10Z","message":{"role":"user","content":"the log shows after every run"}} +{"type":"assistant","sessionId":"sess-h","timestamp":"2026-06-26T10:02:20Z","message":{"role":"assistant","content":[{"type":"text","text":"reading it now"}]}} +` + +func TestHarnessWrittenUserRecordsDropped(t *testing.T) { + root := t.TempDir() + writeTranscript(t, root, "-home-u-src-catchup", "sess-h", harnessTranscript, time.Now()) + + p := New() + roots := session.Roots{Claude: root} + src, err := p.Resolve(context.Background(), roots, "sess-h") + if err != nil { + t.Fatal(err) + } + th, err := p.Read(context.Background(), src) + if err != nil { + t.Fatal(err) + } + want := []string{ + "land the parser fix", + // A slash command and a ! bash line are things the user did: the args + // and the command are the request, so both records stay. + "/review\nreview\nthe parser branch", + "ls internal", + "claude\nsession", + // A wrapper that never closes is not the harness's spelling: kept. + "cut short", + "be brief\n\nwhy did the build fail?", // mixed: kept whole + "/mine is my own tag, keep it", + "
hello
why does this not render?", + "my own notes", + "the log shows after every run", + "reading it now", + } + var got []string + for _, e := range th.Entries { + got = append(got, e.Text) + } + if len(got) != len(want) { + t.Fatalf("got %d entries, want %d: %q", len(got), len(want), got) + } + for i, w := range want { + if got[i] != w { + t.Errorf("entry %d = %q, want %q", i, got[i], w) + } + } + // A dropped record's text is searchable nowhere: not in a query, not in a row. + for _, gone := range []string{"the agent reported back", "branch is clean", + "mind the budget", "run locally"} { + if strings.Contains(th.VisibleText(), gone) { + t.Errorf("harness text %q survived into the timeline", gone) + } + } +} + func TestListSkipsSubagents(t *testing.T) { root := t.TempDir() writeTranscript(t, root, "-proj", "main-1", transcript, time.Now())