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
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