Skip to content
Merged
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
68 changes: 66 additions & 2 deletions internal/claude/claude.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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})
}

Expand Down Expand Up @@ -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 (<div>,
// <my-element>) is a turn they meant to send, and <system-reminder-notes> is
// not <system-reminder>.
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 <command-name> 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, "</"+name+">")
if end < 0 {
return false
}
rest = strings.TrimSpace(rest[end:])
}
}

// openTag returns the lowercased name of a bare <name> 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:
Expand Down
73 changes: 73 additions & 0 deletions internal/claude/claude_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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":"<task-notification><task-id>t1</task-id><status>completed</status><result>the agent reported back</result></task-notification>"}}
{"type":"user","sessionId":"sess-h","timestamp":"2026-06-26T10:00:20Z","message":{"role":"user","content":"<command-name>/review</command-name>\n<command-message>review</command-message>\n<command-args>the parser branch</command-args>"}}
{"type":"user","sessionId":"sess-h","timestamp":"2026-06-26T10:00:30Z","message":{"role":"user","content":"<local-command-stdout>branch is clean</local-command-stdout>"}}
{"type":"user","sessionId":"sess-h","timestamp":"2026-06-26T10:00:40Z","message":{"role":"user","content":"<bash-input>ls internal</bash-input>"}}
{"type":"user","sessionId":"sess-h","timestamp":"2026-06-26T10:00:50Z","message":{"role":"user","content":"<bash-stdout>claude\nsession</bash-stdout><bash-stderr></bash-stderr>"}}
{"type":"user","sessionId":"sess-h","timestamp":"2026-06-26T10:01:00Z","message":{"role":"user","content":"\n\n <SYSTEM-REMINDER>mind the budget</SYSTEM-REMINDER>"}}
{"type":"user","sessionId":"sess-h","timestamp":"2026-06-26T10:01:10Z","message":{"role":"user","content":[{"type":"text","text":"<local-command-caveat>the command was run locally</local-command-caveat>"}]}}
{"type":"user","sessionId":"sess-h","timestamp":"2026-06-26T10:01:20Z","message":{"role":"user","content":"<task-notification><result>cut short"}}
{"type":"user","sessionId":"sess-h","timestamp":"2026-06-26T10:01:30Z","message":{"role":"user","content":"<system-reminder>be brief</system-reminder>\n\nwhy did the build fail?"}}
{"type":"user","sessionId":"sess-h","timestamp":"2026-06-26T10:01:40Z","message":{"role":"user","content":[{"type":"text","text":"<command-name>/mine</command-name> is my own tag, keep it"}]}}
{"type":"user","sessionId":"sess-h","timestamp":"2026-06-26T10:01:50Z","message":{"role":"user","content":"<div>hello</div> why does this not render?"}}
{"type":"user","sessionId":"sess-h","timestamp":"2026-06-26T10:02:00Z","message":{"role":"user","content":"<system-reminder-notes>my own notes</system-reminder-notes>"}}
{"type":"user","sessionId":"sess-h","timestamp":"2026-06-26T10:02:10Z","message":{"role":"user","content":"the log shows <task-notification> 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.
"<command-name>/review</command-name>\n<command-message>review</command-message>\n<command-args>the parser branch</command-args>",
"<bash-input>ls internal</bash-input>",
"<bash-stdout>claude\nsession</bash-stdout><bash-stderr></bash-stderr>",
// A wrapper that never closes is not the harness's spelling: kept.
"<task-notification><result>cut short",
"<system-reminder>be brief</system-reminder>\n\nwhy did the build fail?", // mixed: kept whole
"<command-name>/mine</command-name> is my own tag, keep it",
"<div>hello</div> why does this not render?",
"<system-reminder-notes>my own notes</system-reminder-notes>",
"the log shows <task-notification> 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())
Expand Down
Loading