Skip to content
Open
23 changes: 23 additions & 0 deletions cmd/agentsview/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -570,6 +570,29 @@ func TestStartRemoteHostSync_NilEmitterSafe(t *testing.T) {
<-exited
}

func TestCollectWatchRootsHermesSessionsWatchesStateDBParent(t *testing.T) {
root := t.TempDir()
sessionsDir := filepath.Join(root, "sessions")
require.NoError(t, os.Mkdir(sessionsDir, 0o755), "mkdir sessions")

cfg := config.Config{
AgentDirs: map[parser.AgentType][]string{
parser.AgentHermes: {sessionsDir},
},
}

roots, unwatchedDirs := collectWatchRoots(cfg)

require.Empty(t, unwatchedDirs, "unwatched dirs before watcher setup")
require.Len(t, roots, 2)
assert.Equal(t, root, roots[0].root)
assert.True(t, roots[0].shallow)
assert.Equal(t, []string{sessionsDir}, roots[0].dirs)
assert.Equal(t, sessionsDir, roots[1].root)
assert.False(t, roots[1].shallow)
assert.Equal(t, []string{sessionsDir}, roots[1].dirs)
}

func TestResyncCoversSignals(t *testing.T) {
tests := []struct {
name string
Expand Down
79 changes: 48 additions & 31 deletions internal/parser/claude.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,24 +56,17 @@ type claudeQueuedCommand struct {
timestamp time.Time
}

// ParseClaudeSession parses a Claude Code JSONL session file.
// Returns one or more ParseResult structs (multiple when forks
// are detected in the uuid/parentUuid DAG).
func ParseClaudeSession(
path, project, machine string,
) ([]ParseResult, error) {
results, _, err := ParseClaudeSessionWithExclusions(
path, project, machine,
)
return results, err
}

// ParseClaudeSessionWithExclusions parses a Claude Code JSONL
// session file and also returns session IDs intentionally excluded
// from the archive, such as content-free /usage probes. Sync uses
// those IDs during full resync so orphan preservation does not
// restore rows the current parser deliberately dropped.
func ParseClaudeSessionWithExclusions(
// claudeParseWithExclusions parses a Claude Code JSONL session file
// and also returns session IDs intentionally excluded from the
// archive, such as content-free /usage probes. Sync uses those IDs
// during full resync so orphan preservation does not restore rows the
// current parser deliberately dropped. This is the provider-owned
// parse body shared by the Claude provider (both its discovered-session
// Parse path and its ParseUploadedTranscript entry) and the Cowork
// parser (which reuses the Claude transcript format); it carries no
// legacy entrypoint naming so the provider can call it without shimming
// a Parse* free function.
func claudeParseWithExclusions(
path, project, machine string,
) ([]ParseResult, []string, error) {
info, err := os.Stat(path)
Expand Down Expand Up @@ -366,15 +359,17 @@ func lastAssistantStopReason(messages []ParsedMessage) string {
return ""
}

// ParseClaudeSessionFrom parses only new lines from a Claude
// JSONL file starting at the given byte offset. Returns only
// the newly parsed messages (with ordinals starting at
// startOrdinal) and the latest timestamp. Fork detection is
// skipped — new entries are processed linearly. Used for
// incremental re-parsing of append-only session files.
// ErrDAGDetected is returned by ParseClaudeSessionFrom when
// appended lines contain uuid fields that require DAG-aware
// fork detection, which incremental parsing cannot handle.
// claudeParseSessionFrom parses only new lines from a Claude JSONL
// file starting at the given byte offset. Returns only the newly
// parsed messages (with ordinals starting at startOrdinal) and the
// latest timestamp. Fork detection is skipped — new entries are
// processed linearly. Used by the Claude provider for incremental
// re-parsing of append-only session files. ErrDAGDetected is returned
// when appended lines contain uuid fields that require DAG-aware fork
// detection, which incremental parsing cannot handle. This is the
// provider-owned incremental body; it carries no legacy entrypoint
// naming so the provider can call it without shimming a Parse* free
// function.
var ErrDAGDetected = fmt.Errorf(
"incremental parse: DAG uuid detected",
)
Expand All @@ -387,11 +382,33 @@ var ErrClaudeIncrementalNeedsFullParse = fmt.Errorf(
"incremental parse: appended Claude lines require full parse",
)

// ParseClaudeSessionWithExclusions and ParseClaudeSessionFrom are the exported
// seam used by the S3 sync path (internal/sync), which buffers an s3:// object
// to a temp file and parses it through the legacy per-agent processor. The
// Claude provider calls the unexported claudeParse* bodies directly; these thin
// wrappers exist only so the cross-package S3 consumer can reach the same logic
// without a provider file shimming a Parse* free function. They are removed once
// S3 support folds into the JSONL source sets.
func ParseClaudeSessionWithExclusions(
path, project, machine string,
) ([]ParseResult, []string, error) {
return claudeParseWithExclusions(path, project, machine)
}

func ParseClaudeSessionFrom(
path string,
offset int64,
startOrdinal int,
lastEntryUUID string,
) ([]ParsedMessage, time.Time, int64, error) {
return claudeParseSessionFrom(path, offset, startOrdinal, lastEntryUUID)
}

func claudeParseSessionFrom(
path string,
offset int64,
startOrdinal int,
lastEntryUUID string,
) ([]ParsedMessage, time.Time, int64, error) {
var (
entries []dagEntry
Expand Down Expand Up @@ -726,7 +743,7 @@ func extractMessagesFrom(
}

if e.entryType == "user" {
if subtype := ClassifyClaudeSystemMessage(text); subtype != "" {
if subtype := classifyClaudeSystemMessage(text); subtype != "" {
// Preserve Role=user so analytics that compute
// turn-cycle/throughput on role alone (see
// internal/db/analytics.go) don't count these as
Expand Down Expand Up @@ -1666,7 +1683,7 @@ func extractMessages(entries []dagEntry) (
// stays "user" so role-keyed analytics continue to treat
// these as inputs, not assistant replies.
if e.entryType == "user" {
if subtype := ClassifyClaudeSystemMessage(text); subtype != "" {
if subtype := classifyClaudeSystemMessage(text); subtype != "" {
messages = append(messages, ParsedMessage{
Ordinal: ordinal,
Role: RoleUser,
Expand Down Expand Up @@ -2079,14 +2096,14 @@ func extractCompactSummary(line string) string {
return content.Str
}

// ClassifyClaudeSystemMessage inspects a user-entry content string and
// classifyClaudeSystemMessage inspects a user-entry content string and
// returns the matched system subtype (e.g. "continuation", "resume"),
// or "" if the content is an ordinary user message.
//
// Non-caveat <local-command-*> envelopes (stdout/stderr surrounds for
// local command output) are treated as regular noise and return "";
// only the caveat variant is a semantic "resume" marker.
func ClassifyClaudeSystemMessage(content string) string {
func classifyClaudeSystemMessage(content string) string {
trimmed := strings.TrimLeftFunc(content, func(r rune) bool {
return r == '\uFEFF' || unicode.IsSpace(r)
})
Expand Down
16 changes: 8 additions & 8 deletions internal/parser/claude_parser_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ func runClaudeParserTest(t *testing.T, fileName, content string) (ParsedSession,
fileName = "test.jsonl"
}
path := createTestFile(t, fileName, content)
results, err := ParseClaudeSession(path, "my_app", "local")
results, err := parseClaudeSession(path, "my_app", "local")
require.NoError(t, err)
require.NotEmpty(t, results)
return results[0].Session, results[0].Messages
Expand All @@ -31,7 +31,7 @@ func runClaudeParserTest(t *testing.T, fileName, content string) (ParsedSession,
func callParseClaudeSessionFrom(
path string, offset int64, startOrdinal int, lastEntryUUID string,
) ([]ParsedMessage, time.Time, int64, error) {
fn := reflect.ValueOf(ParseClaudeSessionFrom)
fn := reflect.ValueOf(claudeParseSessionFrom)
args := []reflect.Value{
reflect.ValueOf(path),
reflect.ValueOf(offset),
Expand Down Expand Up @@ -68,7 +68,7 @@ func TestParseClaudeSession_UsageProbe(t *testing.T) {
parse := func(t *testing.T, content string) []ParseResult {
t.Helper()
path := createTestFile(t, "probe.jsonl", content)
results, err := ParseClaudeSession(path, "ClaudeProbe", "local")
results, err := parseClaudeSession(path, "ClaudeProbe", "local")
require.NoError(t, err)
return results
}
Expand Down Expand Up @@ -516,7 +516,7 @@ func TestParseClaudeSessionFrom_Incremental(t *testing.T) {
path := createTestFile(t, "inc-claude.jsonl", initial)

// Full parse to get baseline.
results, err := ParseClaudeSession(path, "proj", "local")
results, err := parseClaudeSession(path, "proj", "local")
require.NoError(t, err)
require.NotEmpty(t, results)
assert.Equal(t, 2, len(results[0].Messages))
Expand Down Expand Up @@ -978,7 +978,7 @@ func TestParseClaudeSession_ResolvesPersistedToolResultOutput(
sessionPath := filepath.Join(dir, "project", "parent-session.jsonl")
require.NoError(t, os.WriteFile(sessionPath, []byte(content), 0o644))

results, err := ParseClaudeSession(sessionPath, "project", "local")
results, err := parseClaudeSession(sessionPath, "project", "local")
require.NoError(t, err)
require.Len(t, results, 1)
require.Len(t, results[0].Messages, 3)
Expand Down Expand Up @@ -1016,7 +1016,7 @@ func TestParseClaudeSession_PersistedToolResultDoesNotOverwriteSiblings(
sessionPath := filepath.Join(dir, "project", "parent-session.jsonl")
require.NoError(t, os.WriteFile(sessionPath, []byte(content), 0o644))

results, err := ParseClaudeSession(sessionPath, "project", "local")
results, err := parseClaudeSession(sessionPath, "project", "local")
require.NoError(t, err)
require.Len(t, results, 1)
require.Len(t, results[0].Messages, 3)
Expand Down Expand Up @@ -1406,7 +1406,7 @@ func TestParseClaudeSession_ExtractsMessageIDAndRequestID(t *testing.T) {
t.Fatalf("write fixture: %v", err)
}

results, err := ParseClaudeSession(path, "proj", "m")
results, err := parseClaudeSession(path, "proj", "m")
if err != nil {
t.Fatalf("parse: %v", err)
}
Expand Down Expand Up @@ -1779,7 +1779,7 @@ func TestClassifyClaudeSystemMessage(t *testing.T) {
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := ClassifyClaudeSystemMessage(c.content)
got := classifyClaudeSystemMessage(c.content)
assert.Equal(t, c.expected, got)
})
}
Expand Down
Loading
Loading