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
1964 lines
69 KiB
Go
1964 lines
69 KiB
Go
package parser
|
|
|
|
import (
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
"unicode/utf8"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"github.com/tidwall/gjson"
|
|
"go.kenn.io/agentsview/internal/testjsonl"
|
|
)
|
|
|
|
func runClaudeParserTest(t *testing.T, fileName, content string) (ParsedSession, []ParsedMessage) {
|
|
t.Helper()
|
|
if fileName == "" {
|
|
fileName = "test.jsonl"
|
|
}
|
|
path := createTestFile(t, fileName, content)
|
|
results, err := parseClaudeSession(path, "my_app", "local")
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, results)
|
|
return results[0].Session, results[0].Messages
|
|
}
|
|
|
|
func callParseClaudeSessionFrom(
|
|
path string, offset int64, startOrdinal int, lastEntryUUID string,
|
|
) ([]ParsedMessage, time.Time, int64, error) {
|
|
msgs, _, endedAt, consumed, err := callParseClaudeSessionFromWithLinks(
|
|
path, offset, startOrdinal, lastEntryUUID,
|
|
)
|
|
return msgs, endedAt, consumed, err
|
|
}
|
|
|
|
func callParseClaudeSessionFromWithLinks(
|
|
path string, offset int64, startOrdinal int, lastEntryUUID string,
|
|
) ([]ParsedMessage, []ClaudeSubagentLink, time.Time, int64, error) {
|
|
fn := reflect.ValueOf(claudeParseSessionFrom)
|
|
args := []reflect.Value{
|
|
reflect.ValueOf(path),
|
|
reflect.ValueOf(offset),
|
|
reflect.ValueOf(startOrdinal),
|
|
}
|
|
if fn.Type().NumIn() == 4 {
|
|
args = append(args, reflect.ValueOf(lastEntryUUID))
|
|
}
|
|
out := fn.Call(args)
|
|
|
|
var msgs []ParsedMessage
|
|
if !out[0].IsNil() {
|
|
msgs = out[0].Interface().([]ParsedMessage)
|
|
}
|
|
var links []ClaudeSubagentLink
|
|
endedIdx := 1
|
|
consumedIdx := 2
|
|
errIdx := 3
|
|
if len(out) == 5 {
|
|
if !out[1].IsNil() {
|
|
links = out[1].Interface().([]ClaudeSubagentLink)
|
|
}
|
|
endedIdx = 2
|
|
consumedIdx = 3
|
|
errIdx = 4
|
|
}
|
|
endedAt := out[endedIdx].Interface().(time.Time)
|
|
consumed := out[consumedIdx].Interface().(int64)
|
|
|
|
var err error
|
|
if !out[errIdx].IsNil() {
|
|
err = out[errIdx].Interface().(error)
|
|
}
|
|
return msgs, links, endedAt, consumed, err
|
|
}
|
|
|
|
// TestParseClaudeSession_UsageProbe verifies that sessions whose only
|
|
// real user turn is the /usage command (content-free usage probes such
|
|
// as CodexBar's ClaudeProbe) are skipped during parsing, while sessions
|
|
// that also contain a real prompt or a different command are kept.
|
|
func TestParseClaudeSession_UsageProbe(t *testing.T) {
|
|
const usageCmd = "<command-name>/usage</command-name>\n" +
|
|
" <command-message>usage</command-message>\n" +
|
|
" <command-args></command-args>"
|
|
|
|
parse := func(t *testing.T, content string) []ParseResult {
|
|
t.Helper()
|
|
path := createTestFile(t, "probe.jsonl", content)
|
|
results, err := parseClaudeSession(path, "ClaudeProbe", "local")
|
|
require.NoError(t, err)
|
|
return results
|
|
}
|
|
|
|
t.Run("skips a /usage-only session", func(t *testing.T) {
|
|
content := testjsonl.ClaudeUserJSON(usageCmd, tsEarly)
|
|
assert.Empty(t, parse(t, content),
|
|
"a session whose only user turn is /usage must be skipped")
|
|
})
|
|
|
|
t.Run("skips a queued /usage-only session", func(t *testing.T) {
|
|
content := testjsonl.JoinJSONL(
|
|
testjsonl.ClaudeAssistantJSON("checking usage", tsEarly),
|
|
testjsonl.ClaudeQueuedCommandJSON("/usage", tsEarlyS1),
|
|
)
|
|
assert.Empty(t, parse(t, content),
|
|
"a queued /usage with no real prompt must be skipped")
|
|
})
|
|
|
|
t.Run("skips the real exemplar shape (caveat then /usage)", func(t *testing.T) {
|
|
// The actual CodexBar probe leads with a <local-command-caveat>
|
|
// marker (classified as a system message) before the /usage
|
|
// command, with no assistant turn. The caveat must not count as a
|
|
// real user turn, leaving /usage as the sole turn so the probe is
|
|
// skipped.
|
|
content := testjsonl.JoinJSONL(
|
|
testjsonl.ClaudeUserJSON(
|
|
"<local-command-caveat>Caveat: The messages below were "+
|
|
"generated by the user while running local commands. DO NOT "+
|
|
"respond to these messages unless explicitly asked."+
|
|
"</local-command-caveat>",
|
|
tsEarly,
|
|
),
|
|
testjsonl.ClaudeUserJSON(usageCmd, tsEarlyS1),
|
|
)
|
|
assert.Empty(t, parse(t, content),
|
|
"caveat is a system marker, so the real probe shape must be skipped")
|
|
})
|
|
|
|
t.Run("keeps a session with /usage and a real prompt", func(t *testing.T) {
|
|
content := testjsonl.JoinJSONL(
|
|
testjsonl.ClaudeUserJSON(usageCmd, tsEarly),
|
|
testjsonl.ClaudeUserJSON("now fix the login bug", tsEarlyS1),
|
|
testjsonl.ClaudeAssistantJSON("on it", tsEarlyS5),
|
|
)
|
|
results := parse(t, content)
|
|
require.Len(t, results, 1)
|
|
assert.Equal(t, "now fix the login bug",
|
|
results[0].Session.FirstMessage)
|
|
})
|
|
|
|
t.Run("keeps a non-/usage command-only session", func(t *testing.T) {
|
|
content := testjsonl.ClaudeUserJSON(
|
|
"<command-name>/clear</command-name>\n<command-args></command-args>",
|
|
tsEarly,
|
|
)
|
|
assert.Len(t, parse(t, content), 1,
|
|
"only /usage probes are skipped, not other commands")
|
|
})
|
|
}
|
|
|
|
func TestParseClaudeSession_Basic(t *testing.T) {
|
|
content := loadFixture(t, "claude/valid_session.jsonl")
|
|
sess, msgs := runClaudeParserTest(t, "test.jsonl", content)
|
|
|
|
assertMessageCount(t, len(msgs), 4)
|
|
assertMessageCount(t, sess.MessageCount, 4)
|
|
assertSessionMeta(t, &sess, "test", "my_app", AgentClaude)
|
|
assert.Equal(t, "Fix the login bug", sess.FirstMessage)
|
|
|
|
assertMessage(t, msgs[0], RoleUser, "")
|
|
assertMessage(t, msgs[1], RoleAssistant, "")
|
|
assert.True(t, msgs[1].HasToolUse)
|
|
assertToolCalls(t, msgs[1].ToolCalls, []ParsedToolCall{{ToolUseID: "toolu_1", ToolName: "Read", Category: "Read", InputJSON: `{"file_path":"src/auth.ts"}`}})
|
|
assert.Equal(t, 0, msgs[0].Ordinal)
|
|
assert.Equal(t, 1, msgs[1].Ordinal)
|
|
}
|
|
|
|
func TestParseClaudeSession_HyphenatedFilename(t *testing.T) {
|
|
content := loadFixture(t, "claude/valid_session.jsonl")
|
|
sess, _ := runClaudeParserTest(t, "my-test-session.jsonl", content)
|
|
assert.Equal(t, "my-test-session", sess.ID)
|
|
}
|
|
|
|
func TestParseClaudeSession_EdgeCases(t *testing.T) {
|
|
t.Run("empty file", func(t *testing.T) {
|
|
sess, msgs := runClaudeParserTest(t, "test.jsonl", "")
|
|
assert.Empty(t, msgs)
|
|
assert.Equal(t, 0, sess.MessageCount)
|
|
})
|
|
|
|
t.Run("skips blank content", func(t *testing.T) {
|
|
content := testjsonl.JoinJSONL(
|
|
testjsonl.ClaudeUserJSON("", tsZero),
|
|
testjsonl.ClaudeUserJSON(" ", tsZeroS1),
|
|
testjsonl.ClaudeUserJSON("actual message", tsZeroS2),
|
|
)
|
|
sess, _ := runClaudeParserTest(t, "test.jsonl", content)
|
|
assert.Equal(t, 1, sess.MessageCount)
|
|
})
|
|
|
|
t.Run("truncates long first message", func(t *testing.T) {
|
|
content := testjsonl.ClaudeUserJSON(generateLargeString(400), tsZero) + "\n"
|
|
sess, _ := runClaudeParserTest(t, "test.jsonl", content)
|
|
assert.Equal(t, 303, len(sess.FirstMessage))
|
|
})
|
|
|
|
t.Run("skips invalid JSON lines", func(t *testing.T) {
|
|
content := "not valid json\n" +
|
|
testjsonl.ClaudeUserJSON("hello", tsZero) + "\n" +
|
|
"also not valid\n"
|
|
sess, _ := runClaudeParserTest(t, "test.jsonl", content)
|
|
assert.Equal(t, 1, sess.MessageCount)
|
|
})
|
|
|
|
t.Run("malformed UTF-8", func(t *testing.T) {
|
|
badUTF8 := `{"type":"user","timestamp":"` + tsZeroS1 + `","message":{"content":"bad ` + string([]byte{0xff, 0xfe}) + `"}}` + "\n"
|
|
content := testjsonl.ClaudeUserJSON("valid message", tsZero) + "\n" + badUTF8
|
|
sess, _ := runClaudeParserTest(t, "test.jsonl", content)
|
|
assert.GreaterOrEqual(t, sess.MessageCount, 1)
|
|
})
|
|
|
|
t.Run("very large message", func(t *testing.T) {
|
|
content := testjsonl.ClaudeUserJSON(generateLargeString(1024*1024), tsZero) + "\n"
|
|
_, msgs := runClaudeParserTest(t, "test.jsonl", content)
|
|
assert.Equal(t, 1024*1024, msgs[0].ContentLength)
|
|
})
|
|
|
|
t.Run("skips empty lines in file", func(t *testing.T) {
|
|
content := "\n\n" +
|
|
testjsonl.ClaudeUserJSON("msg1", tsZero) +
|
|
"\n \n\t\n" +
|
|
testjsonl.ClaudeAssistantJSON([]map[string]any{{"type": "text", "text": "reply"}}, tsZeroS1) +
|
|
"\n\n"
|
|
sess, _ := runClaudeParserTest(t, "test.jsonl", content)
|
|
assert.Equal(t, 2, sess.MessageCount)
|
|
})
|
|
|
|
t.Run("skips partial/truncated JSON", func(t *testing.T) {
|
|
content := testjsonl.ClaudeUserJSON("first", tsZero) + "\n" +
|
|
`{"type":"user","truncated` + "\n" +
|
|
testjsonl.ClaudeAssistantJSON([]map[string]any{{"type": "text", "text": "last"}}, tsZeroS2) + "\n"
|
|
sess, _ := runClaudeParserTest(t, "test.jsonl", content)
|
|
assert.Equal(t, 2, sess.MessageCount)
|
|
})
|
|
}
|
|
|
|
func TestParseClaudeSession_SkippedMessages(t *testing.T) {
|
|
|
|
t.Run("skips isMeta user messages", func(t *testing.T) {
|
|
content := testjsonl.JoinJSONL(
|
|
testjsonl.ClaudeMetaUserJSON("meta context", tsZero, true, false),
|
|
testjsonl.ClaudeUserJSON("real question", tsZeroS1),
|
|
)
|
|
sess, _ := runClaudeParserTest(t, "test.jsonl", content)
|
|
assert.Equal(t, 1, sess.MessageCount)
|
|
assert.Equal(t, "real question", sess.FirstMessage)
|
|
})
|
|
|
|
t.Run("persists isCompactSummary as system message", func(t *testing.T) {
|
|
compactLine := `{"type":"user","isCompactSummary":true,` +
|
|
`"message":{"role":"user","content":[` +
|
|
`{"type":"text","text":"Summary of conversation so far..."}` +
|
|
`]},"uuid":"compact-uuid","parentUuid":"parent-uuid",` +
|
|
`"isSidechain":false,"timestamp":"` + tsZero + `"}`
|
|
content := testjsonl.JoinJSONL(
|
|
compactLine,
|
|
testjsonl.ClaudeUserJSON("actual prompt", tsZeroS1),
|
|
)
|
|
sess, msgs := runClaudeParserTest(t, "test.jsonl", content)
|
|
// Compact boundary + real user message = 2 messages.
|
|
assert.Equal(t, 2, sess.MessageCount)
|
|
// Only real user messages count toward UserMessageCount.
|
|
assert.Equal(t, 1, sess.UserMessageCount)
|
|
// FirstMessage is from the first real user message.
|
|
assert.Equal(t, "actual prompt", sess.FirstMessage)
|
|
|
|
// Verify compact boundary message fields.
|
|
cb := msgs[0]
|
|
assert.Equal(t, RoleAssistant, cb.Role)
|
|
assert.True(t, cb.IsSystem)
|
|
assert.True(t, cb.IsCompactBoundary)
|
|
assert.Equal(t, "system", cb.SourceType)
|
|
assert.Equal(t, "compact_boundary", cb.SourceSubtype)
|
|
assert.Equal(t, "compact-uuid", cb.SourceUUID)
|
|
assert.Equal(t, "parent-uuid", cb.SourceParentUUID)
|
|
assert.False(t, cb.IsSidechain)
|
|
assert.Contains(t, cb.Content, "Summary of conversation so far...")
|
|
assert.Equal(t, 0, cb.Ordinal)
|
|
|
|
// Real user message follows with next ordinal.
|
|
assert.Equal(t, 1, msgs[1].Ordinal)
|
|
assert.Equal(t, RoleUser, msgs[1].Role)
|
|
})
|
|
|
|
t.Run("promotes and skips system-injected patterns", func(t *testing.T) {
|
|
content := testjsonl.JoinJSONL(
|
|
testjsonl.ClaudeUserJSON("This session is being continued from a previous conversation.", tsZero),
|
|
testjsonl.ClaudeUserJSON("[Request interrupted by user]", tsZeroS1),
|
|
testjsonl.ClaudeUserJSON("<local-command-caveat>Caveat: resumed</local-command-caveat>", tsZeroS2),
|
|
testjsonl.ClaudeUserJSON("<task-notification>data</task-notification>", "2024-01-01T00:00:04Z"),
|
|
// Non-caveat local-command is pure noise and stays skipped.
|
|
testjsonl.ClaudeUserJSON("<local-command-result>ok</local-command-result>", "2024-01-01T00:00:05Z"),
|
|
testjsonl.ClaudeUserJSON("Stop hook feedback: rejected", "2024-01-01T00:00:06Z"),
|
|
testjsonl.ClaudeUserJSON("real user message", "2024-01-01T00:00:07Z"),
|
|
)
|
|
sess, msgs := runClaudeParserTest(t, "test.jsonl", content)
|
|
// 5 promoted system + 1 real user; <local-command-result>
|
|
// is still skipped.
|
|
assert.Equal(t, 6, sess.MessageCount)
|
|
assert.Equal(t, 1, sess.UserMessageCount)
|
|
assert.Equal(t, "real user message", sess.FirstMessage)
|
|
|
|
wantSubtypes := []string{
|
|
"continuation", "interrupted", "resume",
|
|
"task_notification", "stop_hook",
|
|
}
|
|
require.Len(t, msgs, 6)
|
|
for i, want := range wantSubtypes {
|
|
assert.True(t, msgs[i].IsSystem,
|
|
"msgs[%d] should be system", i)
|
|
// Promoted system markers keep Role=user so
|
|
// role-keyed analytics don't count them as
|
|
// assistant replies.
|
|
assert.Equal(t, RoleUser, msgs[i].Role)
|
|
assert.Equal(t, "system", msgs[i].SourceType)
|
|
assert.Equal(t, want, msgs[i].SourceSubtype)
|
|
}
|
|
// Final message is the real user message.
|
|
assert.False(t, msgs[5].IsSystem)
|
|
assert.Equal(t, RoleUser, msgs[5].Role)
|
|
assert.Equal(t, "real user message", msgs[5].Content)
|
|
})
|
|
|
|
t.Run("skill invocation shown as user message", func(t *testing.T) {
|
|
content := testjsonl.JoinJSONL(
|
|
testjsonl.ClaudeUserJSON(
|
|
"<command-message>roborev-fix</command-message>\n<command-name>/roborev-fix</command-name>\n<command-args>450</command-args>",
|
|
tsZero,
|
|
),
|
|
testjsonl.ClaudeAssistantJSON([]map[string]any{
|
|
{"type": "text", "text": "Looking at issue 450..."},
|
|
}, tsZeroS1),
|
|
)
|
|
sess, msgs := runClaudeParserTest(t, "test.jsonl", content)
|
|
assert.Equal(t, 2, sess.MessageCount)
|
|
assert.Equal(t, 1, sess.UserMessageCount)
|
|
assert.Equal(t, "", sess.FirstMessage, "slash command with no follow-up yields empty first_message")
|
|
assert.Equal(t, RoleUser, msgs[0].Role)
|
|
assert.Equal(t, "/roborev-fix 450", msgs[0].Content)
|
|
})
|
|
|
|
t.Run("skill invocation without args shown as user message", func(t *testing.T) {
|
|
content := testjsonl.JoinJSONL(
|
|
testjsonl.ClaudeUserJSON(
|
|
"<command-message>superpowers:brainstorming</command-message>\n<command-name>/superpowers:brainstorming</command-name>",
|
|
tsZero,
|
|
),
|
|
testjsonl.ClaudeAssistantJSON([]map[string]any{
|
|
{"type": "text", "text": "Starting brainstorming..."},
|
|
}, tsZeroS1),
|
|
)
|
|
sess, msgs := runClaudeParserTest(t, "test.jsonl", content)
|
|
assert.Equal(t, 2, sess.MessageCount)
|
|
assert.Equal(t, "/superpowers:brainstorming", sess.FirstMessage)
|
|
assert.Equal(t, RoleUser, msgs[0].Role)
|
|
assert.Equal(t, "/superpowers:brainstorming", msgs[0].Content)
|
|
})
|
|
|
|
t.Run("assistant with system-like content not filtered", func(t *testing.T) {
|
|
content := testjsonl.JoinJSONL(
|
|
testjsonl.ClaudeUserJSON("hello", tsZero),
|
|
testjsonl.ClaudeAssistantJSON([]map[string]any{
|
|
{"type": "text", "text": "This session is being continued from a previous conversation."},
|
|
}, tsZeroS1),
|
|
)
|
|
sess, _ := runClaudeParserTest(t, "test.jsonl", content)
|
|
assert.Equal(t, 2, sess.MessageCount)
|
|
})
|
|
|
|
t.Run("firstMsg from first non-system user message", func(t *testing.T) {
|
|
content := testjsonl.JoinJSONL(
|
|
testjsonl.ClaudeMetaUserJSON("context data", tsZero, true, false),
|
|
testjsonl.ClaudeUserJSON("This session is being continued from a previous conversation.", tsZeroS1),
|
|
testjsonl.ClaudeUserJSON("Fix the auth bug", tsZeroS2),
|
|
)
|
|
sess, _ := runClaudeParserTest(t, "test.jsonl", content)
|
|
// Meta user is skipped, continuation is promoted to a
|
|
// system message, real user is kept.
|
|
assert.Equal(t, 2, sess.MessageCount)
|
|
assert.Equal(t, 1, sess.UserMessageCount)
|
|
assert.Equal(t, "Fix the auth bug", sess.FirstMessage)
|
|
})
|
|
}
|
|
|
|
func TestParseClaudeSession_QueuedCommand(t *testing.T) {
|
|
t.Run("surfaces as user message between turns", func(t *testing.T) {
|
|
content := testjsonl.JoinJSONL(
|
|
testjsonl.ClaudeUserJSON("first request", tsZero),
|
|
testjsonl.ClaudeAssistantJSON([]map[string]any{
|
|
{"type": "text", "text": "starting work"},
|
|
}, tsZeroS1),
|
|
testjsonl.ClaudeQueuedCommandJSON(
|
|
"hold on, also do X", tsZeroS2,
|
|
),
|
|
testjsonl.ClaudeAssistantJSON([]map[string]any{
|
|
{"type": "text", "text": "OK doing X too"},
|
|
}, "2024-01-01T00:00:03Z"),
|
|
)
|
|
sess, msgs := runClaudeParserTest(t, "test.jsonl", content)
|
|
require.Len(t, msgs, 4)
|
|
assert.Equal(t, 4, sess.MessageCount)
|
|
// Original first user + queued command both count.
|
|
assert.Equal(t, 2, sess.UserMessageCount)
|
|
assert.Equal(t, "first request", sess.FirstMessage)
|
|
|
|
assert.Equal(t, RoleUser, msgs[0].Role)
|
|
assert.Equal(t, "first request", msgs[0].Content)
|
|
|
|
assert.Equal(t, RoleAssistant, msgs[1].Role)
|
|
|
|
assert.Equal(t, RoleUser, msgs[2].Role)
|
|
assert.Equal(t, "hold on, also do X", msgs[2].Content)
|
|
assert.False(t, msgs[2].IsSystem)
|
|
assert.Equal(t, "user", msgs[2].SourceType)
|
|
assert.Equal(t, "queued_command", msgs[2].SourceSubtype)
|
|
|
|
assert.Equal(t, RoleAssistant, msgs[3].Role)
|
|
|
|
for i, m := range msgs {
|
|
assert.Equal(t, i, m.Ordinal,
|
|
"ordinal mismatch at %d", i)
|
|
}
|
|
})
|
|
|
|
t.Run("becomes first message when session opens with one", func(t *testing.T) {
|
|
content := testjsonl.JoinJSONL(
|
|
testjsonl.ClaudeQueuedCommandJSON(
|
|
"queued opener", tsZero,
|
|
),
|
|
testjsonl.ClaudeAssistantJSON([]map[string]any{
|
|
{"type": "text", "text": "ok"},
|
|
}, tsZeroS1),
|
|
)
|
|
sess, msgs := runClaudeParserTest(t, "test.jsonl", content)
|
|
require.Len(t, msgs, 2)
|
|
assert.Equal(t, 1, sess.UserMessageCount)
|
|
assert.Equal(t, "queued opener", sess.FirstMessage)
|
|
assert.Equal(t, "queued_command", msgs[0].SourceSubtype)
|
|
})
|
|
|
|
t.Run("empty prompt is skipped", func(t *testing.T) {
|
|
content := testjsonl.JoinJSONL(
|
|
testjsonl.ClaudeUserJSON("hi", tsZero),
|
|
testjsonl.ClaudeQueuedCommandJSON(" ", tsZeroS1),
|
|
testjsonl.ClaudeAssistantJSON([]map[string]any{
|
|
{"type": "text", "text": "ok"},
|
|
}, tsZeroS2),
|
|
)
|
|
sess, msgs := runClaudeParserTest(t, "test.jsonl", content)
|
|
assert.Equal(t, 2, sess.MessageCount)
|
|
require.Len(t, msgs, 2)
|
|
})
|
|
|
|
t.Run("non-queued attachment types are dropped", func(t *testing.T) {
|
|
taskReminder := `{"type":"attachment","timestamp":"` +
|
|
tsZeroS1 + `","attachment":{"type":"task_reminder",` +
|
|
`"content":"reminder"}}`
|
|
content := testjsonl.JoinJSONL(
|
|
testjsonl.ClaudeUserJSON("hi", tsZero),
|
|
taskReminder,
|
|
testjsonl.ClaudeAssistantJSON([]map[string]any{
|
|
{"type": "text", "text": "ok"},
|
|
}, tsZeroS2),
|
|
)
|
|
sess, msgs := runClaudeParserTest(t, "test.jsonl", content)
|
|
assert.Equal(t, 2, sess.MessageCount)
|
|
require.Len(t, msgs, 2)
|
|
})
|
|
|
|
t.Run("works in DAG sessions with uuids on real entries", func(t *testing.T) {
|
|
// Real entries form a uuid chain; the attachment has
|
|
// no uuid but must still surface as a user message.
|
|
userLine := `{"type":"user","uuid":"u1","timestamp":"` +
|
|
tsZero + `","message":{"content":"hi"}}`
|
|
assistant1 := `{"type":"assistant","uuid":"a1",` +
|
|
`"parentUuid":"u1","timestamp":"` + tsZeroS1 +
|
|
`","message":{"content":[{"type":"text",` +
|
|
`"text":"work"}]}}`
|
|
attachment := testjsonl.ClaudeQueuedCommandJSON(
|
|
"also do X", tsZeroS2,
|
|
)
|
|
assistant2 := `{"type":"assistant","uuid":"a2",` +
|
|
`"parentUuid":"a1","timestamp":` +
|
|
`"2024-01-01T00:00:03Z","message":{"content":[` +
|
|
`{"type":"text","text":"done"}]}}`
|
|
content := testjsonl.JoinJSONL(
|
|
userLine, assistant1, attachment, assistant2,
|
|
)
|
|
sess, msgs := runClaudeParserTest(t, "test.jsonl", content)
|
|
require.Len(t, msgs, 4)
|
|
assert.Equal(t, "also do X", msgs[2].Content)
|
|
assert.Equal(t, "queued_command", msgs[2].SourceSubtype)
|
|
assert.Equal(t, 2, sess.UserMessageCount)
|
|
})
|
|
}
|
|
|
|
func TestParseClaudeSession_ParentSessionID(t *testing.T) {
|
|
t.Run("sessionId != fileId sets ParentSessionID", func(t *testing.T) {
|
|
content := testjsonl.JoinJSONL(
|
|
testjsonl.ClaudeUserWithSessionIDJSON("hello", tsZero, "parent-uuid"),
|
|
testjsonl.ClaudeAssistantJSON([]map[string]any{
|
|
{"type": "text", "text": "hi"},
|
|
}, tsZeroS1),
|
|
)
|
|
sess, _ := runClaudeParserTest(t, "test.jsonl", content)
|
|
assert.Equal(t, "parent-uuid", sess.ParentSessionID)
|
|
})
|
|
|
|
t.Run("sessionId == fileId yields empty ParentSessionID", func(t *testing.T) {
|
|
content := testjsonl.JoinJSONL(
|
|
testjsonl.ClaudeUserWithSessionIDJSON("hello", tsZero, "test"),
|
|
)
|
|
sess, _ := runClaudeParserTest(t, "test.jsonl", content)
|
|
assert.Empty(t, sess.ParentSessionID)
|
|
})
|
|
|
|
t.Run("no sessionId field yields empty ParentSessionID", func(t *testing.T) {
|
|
content := testjsonl.JoinJSONL(
|
|
testjsonl.ClaudeUserJSON("hello", tsZero),
|
|
)
|
|
sess, _ := runClaudeParserTest(t, "test.jsonl", content)
|
|
assert.Empty(t, sess.ParentSessionID)
|
|
})
|
|
}
|
|
|
|
func TestParseClaudeSessionFrom_Incremental(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Build initial content: user + assistant.
|
|
initial := testjsonl.JoinJSONL(
|
|
testjsonl.ClaudeUserJSON("hello world", tsEarly),
|
|
testjsonl.ClaudeAssistantJSON("hi there", tsEarlyS1),
|
|
)
|
|
|
|
path := createTestFile(t, "inc-claude.jsonl", initial)
|
|
|
|
// Full parse to get baseline.
|
|
results, err := parseClaudeSession(path, "proj", "local")
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, results)
|
|
assert.Equal(t, 2, len(results[0].Messages))
|
|
assert.Equal(t, 0, results[0].Messages[0].Ordinal)
|
|
assert.Equal(t, 1, results[0].Messages[1].Ordinal)
|
|
|
|
// Record file size as the incremental offset.
|
|
info, err := os.Stat(path)
|
|
require.NoError(t, err)
|
|
offset := info.Size()
|
|
|
|
// Append new messages.
|
|
appended := testjsonl.JoinJSONL(
|
|
testjsonl.ClaudeUserJSON("follow up", tsEarlyS5),
|
|
testjsonl.ClaudeAssistantJSON("got it", tsLate),
|
|
)
|
|
f, err := os.OpenFile(
|
|
path, os.O_APPEND|os.O_WRONLY, 0o644,
|
|
)
|
|
require.NoError(t, err)
|
|
_, err = f.WriteString(appended)
|
|
require.NoError(t, err)
|
|
require.NoError(t, f.Close())
|
|
|
|
// Incremental parse from offset.
|
|
newMsgs, endedAt, _, err := callParseClaudeSessionFrom(
|
|
path, offset, 2, "",
|
|
)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, 2, len(newMsgs))
|
|
|
|
// Ordinals continue from startOrdinal=2.
|
|
assert.Equal(t, 2, newMsgs[0].Ordinal)
|
|
assert.Equal(t, RoleUser, newMsgs[0].Role)
|
|
assert.Contains(t, newMsgs[0].Content, "follow up")
|
|
|
|
assert.Equal(t, 3, newMsgs[1].Ordinal)
|
|
assert.Equal(t, RoleAssistant, newMsgs[1].Role)
|
|
assert.Contains(t, newMsgs[1].Content, "got it")
|
|
|
|
assert.False(t, endedAt.IsZero())
|
|
}
|
|
|
|
func TestParseClaudeSessionFrom_QueuedCommand(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
initial := testjsonl.JoinJSONL(
|
|
testjsonl.ClaudeUserJSON("hello", tsEarly),
|
|
testjsonl.ClaudeAssistantJSON("hi", tsEarlyS1),
|
|
)
|
|
path := createTestFile(t, "inc-queued.jsonl", initial)
|
|
|
|
info, err := os.Stat(path)
|
|
require.NoError(t, err)
|
|
offset := info.Size()
|
|
|
|
// Append: queued command + assistant turn.
|
|
appended := testjsonl.JoinJSONL(
|
|
testjsonl.ClaudeQueuedCommandJSON("plus do X", tsEarlyS5),
|
|
testjsonl.ClaudeAssistantJSON("done", tsLate),
|
|
)
|
|
f, err := os.OpenFile(
|
|
path, os.O_APPEND|os.O_WRONLY, 0o644,
|
|
)
|
|
require.NoError(t, err)
|
|
_, err = f.WriteString(appended)
|
|
require.NoError(t, err)
|
|
require.NoError(t, f.Close())
|
|
|
|
newMsgs, _, _, err := callParseClaudeSessionFrom(path, offset, 2, "")
|
|
require.NoError(t, err)
|
|
require.Len(t, newMsgs, 2)
|
|
|
|
// queued_command first (earlier timestamp), then assistant.
|
|
assert.Equal(t, RoleUser, newMsgs[0].Role)
|
|
assert.Equal(t, "plus do X", newMsgs[0].Content)
|
|
assert.Equal(t, "queued_command", newMsgs[0].SourceSubtype)
|
|
assert.Equal(t, 2, newMsgs[0].Ordinal)
|
|
|
|
assert.Equal(t, RoleAssistant, newMsgs[1].Role)
|
|
assert.Equal(t, 3, newMsgs[1].Ordinal)
|
|
}
|
|
|
|
func TestParseClaudeSessionFrom_QueueOperationPreservesSubagentMapping(
|
|
t *testing.T,
|
|
) {
|
|
t.Parallel()
|
|
|
|
initial := testjsonl.JoinJSONL(
|
|
testjsonl.ClaudeUserJSON("hello", tsEarly),
|
|
)
|
|
path := createTestFile(t, "inc-subagent.jsonl", initial)
|
|
|
|
info, err := os.Stat(path)
|
|
require.NoError(t, err)
|
|
offset := info.Size()
|
|
|
|
appended := strings.Join([]string{
|
|
`{"type":"assistant","timestamp":"` + tsEarlyS1 + `","uuid":"a1","parentUuid":"u1","message":{"content":[{"type":"tool_use","id":"toolu_agent","name":"Agent","input":{"description":"inspect","subagent_type":"Explore","prompt":"inspect"}}]}}`,
|
|
`{"type":"queue-operation","operation":"enqueue","timestamp":"` + tsEarlyS5 + `","sessionId":"test-session","content":"{\"task_id\":\"child123\",\"tool_use_id\":\"toolu_agent\",\"description\":\"inspect\",\"task_type\":\"local_agent\"}"}`,
|
|
}, "\n") + "\n"
|
|
f, err := os.OpenFile(
|
|
path, os.O_APPEND|os.O_WRONLY, 0o644,
|
|
)
|
|
require.NoError(t, err)
|
|
_, err = f.WriteString(appended)
|
|
require.NoError(t, err)
|
|
require.NoError(t, f.Close())
|
|
|
|
newMsgs, _, _, err := callParseClaudeSessionFrom(path, offset, 1, "")
|
|
require.NoError(t, err)
|
|
require.Len(t, newMsgs, 1)
|
|
require.Len(t, newMsgs[0].ToolCalls, 1)
|
|
assert.Equal(
|
|
t,
|
|
"agent-child123",
|
|
newMsgs[0].ToolCalls[0].SubagentSessionID,
|
|
)
|
|
}
|
|
|
|
func TestParseClaudeSessionFrom_SkipsNonMessages(
|
|
t *testing.T,
|
|
) {
|
|
t.Parallel()
|
|
|
|
// Initial content with a "system" type line mixed in.
|
|
initial := testjsonl.JoinJSONL(
|
|
testjsonl.ClaudeUserJSON("first", tsEarly),
|
|
)
|
|
path := createTestFile(
|
|
t, "inc-claude-skip.jsonl", initial,
|
|
)
|
|
|
|
info, err := os.Stat(path)
|
|
require.NoError(t, err)
|
|
offset := info.Size()
|
|
|
|
// Append a system line followed by a real message.
|
|
appended := `{"type":"system","timestamp":"` +
|
|
tsEarlyS5 + `","message":{}}` + "\n" +
|
|
testjsonl.ClaudeAssistantJSON("response", tsLate) +
|
|
"\n"
|
|
f, err := os.OpenFile(
|
|
path, os.O_APPEND|os.O_WRONLY, 0o644,
|
|
)
|
|
require.NoError(t, err)
|
|
_, err = f.WriteString(appended)
|
|
require.NoError(t, err)
|
|
require.NoError(t, f.Close())
|
|
|
|
newMsgs, _, _, err := callParseClaudeSessionFrom(
|
|
path, offset, 1, "",
|
|
)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, 1, len(newMsgs))
|
|
assert.Equal(t, RoleAssistant, newMsgs[0].Role)
|
|
assert.Equal(t, 1, newMsgs[0].Ordinal)
|
|
}
|
|
|
|
func TestParseClaudeSessionFrom_NoNewData(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
content := testjsonl.JoinJSONL(
|
|
testjsonl.ClaudeUserJSON("only msg", tsEarly),
|
|
)
|
|
path := createTestFile(
|
|
t, "inc-claude-empty.jsonl", content,
|
|
)
|
|
|
|
info, err := os.Stat(path)
|
|
require.NoError(t, err)
|
|
|
|
// Parse from EOF — should return empty.
|
|
newMsgs, endedAt, _, err := callParseClaudeSessionFrom(
|
|
path, info.Size(), 1, "",
|
|
)
|
|
require.NoError(t, err)
|
|
assert.Empty(t, newMsgs)
|
|
assert.True(t, endedAt.IsZero())
|
|
}
|
|
|
|
func TestParseClaudeSessionFrom_PartialLineAtEOF(
|
|
t *testing.T,
|
|
) {
|
|
t.Parallel()
|
|
|
|
initial := testjsonl.JoinJSONL(
|
|
testjsonl.ClaudeUserJSON("hello", tsEarly),
|
|
)
|
|
path := createTestFile(
|
|
t, "inc-partial.jsonl", initial,
|
|
)
|
|
|
|
info, err := os.Stat(path)
|
|
require.NoError(t, err)
|
|
offset := info.Size()
|
|
|
|
// Append a complete line + a partial (truncated) line.
|
|
complete := testjsonl.ClaudeAssistantJSON(
|
|
"complete", tsEarlyS5,
|
|
) + "\n"
|
|
partial := `{"type":"user","timestamp":"` + tsLate
|
|
f, err := os.OpenFile(
|
|
path, os.O_APPEND|os.O_WRONLY, 0o644,
|
|
)
|
|
require.NoError(t, err)
|
|
_, err = f.WriteString(complete + partial)
|
|
require.NoError(t, err)
|
|
require.NoError(t, f.Close())
|
|
|
|
newMsgs, _, consumed, err := callParseClaudeSessionFrom(
|
|
path, offset, 1, "",
|
|
)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, 1, len(newMsgs))
|
|
assert.Equal(t, RoleAssistant, newMsgs[0].Role)
|
|
|
|
// consumed should cover only the complete line, not
|
|
// the partial one.
|
|
assert.Equal(t, int64(len(complete)), consumed)
|
|
}
|
|
|
|
func TestParseClaudeSessionFrom_DAGDetected(
|
|
t *testing.T,
|
|
) {
|
|
t.Parallel()
|
|
|
|
initial := testjsonl.JoinJSONL(
|
|
testjsonl.ClaudeUserJSON("hello", tsEarly),
|
|
)
|
|
path := createTestFile(
|
|
t, "inc-dag.jsonl", initial,
|
|
)
|
|
|
|
info, err := os.Stat(path)
|
|
require.NoError(t, err)
|
|
offset := info.Size()
|
|
|
|
// Append two entries that form a fork: both have the
|
|
// same parentUuid but different uuids.
|
|
fork1 := `{"type":"user","uuid":"child-1",` +
|
|
`"parentUuid":"root-1",` +
|
|
`"timestamp":"` + tsEarlyS5 +
|
|
`","message":{"content":"branch A"}}` + "\n"
|
|
fork2 := `{"type":"assistant","uuid":"child-2",` +
|
|
`"parentUuid":"root-1",` +
|
|
`"timestamp":"` + tsLate +
|
|
`","message":{"content":[` +
|
|
`{"type":"text","text":"branch B"}]}}` + "\n"
|
|
|
|
f, err := os.OpenFile(
|
|
path, os.O_APPEND|os.O_WRONLY, 0o644,
|
|
)
|
|
require.NoError(t, err)
|
|
_, err = f.WriteString(fork1 + fork2)
|
|
require.NoError(t, err)
|
|
require.NoError(t, f.Close())
|
|
|
|
_, _, _, err = callParseClaudeSessionFrom(
|
|
path, offset, 1, "",
|
|
)
|
|
assert.ErrorIs(t, err, ErrDAGDetected)
|
|
}
|
|
|
|
func TestParseClaudeSessionFrom_DAGBoundaryAgainstStoredLastUUID(
|
|
t *testing.T,
|
|
) {
|
|
t.Parallel()
|
|
|
|
path := createTestFile(
|
|
t, "inc-dag-boundary.jsonl",
|
|
testjsonl.JoinJSONL(
|
|
testjsonl.ClaudeUserJSON("hello", tsEarly),
|
|
),
|
|
)
|
|
|
|
info, err := os.Stat(path)
|
|
require.NoError(t, err)
|
|
offset := info.Size()
|
|
|
|
appended := testjsonl.JoinJSONL(
|
|
`{"type":"assistant","uuid":"a1","parentUuid":"wrong-parent","timestamp":"` +
|
|
tsEarlyS5 + `","message":{"content":[{"type":"text","text":"branch"}]}}`,
|
|
)
|
|
f, err := os.OpenFile(
|
|
path, os.O_APPEND|os.O_WRONLY, 0o644,
|
|
)
|
|
require.NoError(t, err)
|
|
_, err = f.WriteString(appended + "\n")
|
|
require.NoError(t, err)
|
|
require.NoError(t, f.Close())
|
|
|
|
_, _, _, err = callParseClaudeSessionFrom(
|
|
path, offset, 1, "stored-tip",
|
|
)
|
|
assert.ErrorIs(t, err, ErrDAGDetected)
|
|
}
|
|
|
|
func TestParseClaudeSessionFrom_DAGAcrossNonUUID(
|
|
t *testing.T,
|
|
) {
|
|
t.Parallel()
|
|
|
|
initial := testjsonl.JoinJSONL(
|
|
testjsonl.ClaudeUserJSON("hello", tsEarly),
|
|
)
|
|
path := createTestFile(
|
|
t, "inc-dag-gap.jsonl", initial,
|
|
)
|
|
|
|
info, err := os.Stat(path)
|
|
require.NoError(t, err)
|
|
offset := info.Size()
|
|
|
|
// Append: UUID entry, then a non-UUID entry (no uuid
|
|
// field), then another UUID entry whose parentUuid
|
|
// doesn't match the first UUID entry. The non-UUID gap
|
|
// must not prevent fork detection.
|
|
line1 := `{"type":"user","uuid":"u1",` +
|
|
`"parentUuid":"pre",` +
|
|
`"timestamp":"` + tsEarlyS5 +
|
|
`","message":{"content":"a"}}` + "\n"
|
|
noUUID := `{"type":"user",` +
|
|
`"timestamp":"` + tsLate +
|
|
`","message":{"content":"gap"}}` + "\n"
|
|
line2 := `{"type":"assistant","uuid":"u2",` +
|
|
`"parentUuid":"other",` +
|
|
`"timestamp":"` + tsLate +
|
|
`","message":{"content":[` +
|
|
`{"type":"text","text":"b"}]}}` + "\n"
|
|
|
|
f, err := os.OpenFile(
|
|
path, os.O_APPEND|os.O_WRONLY, 0o644,
|
|
)
|
|
require.NoError(t, err)
|
|
_, err = f.WriteString(line1 + noUUID + line2)
|
|
require.NoError(t, err)
|
|
require.NoError(t, f.Close())
|
|
|
|
_, _, _, err = callParseClaudeSessionFrom(
|
|
path, offset, 1, "",
|
|
)
|
|
assert.ErrorIs(t, err, ErrDAGDetected)
|
|
}
|
|
|
|
func TestParseClaudeSessionFrom_LinearUUID(
|
|
t *testing.T,
|
|
) {
|
|
t.Parallel()
|
|
|
|
initial := testjsonl.JoinJSONL(
|
|
testjsonl.ClaudeUserJSON("hello", tsEarly),
|
|
)
|
|
path := createTestFile(
|
|
t, "inc-linear-uuid.jsonl", initial,
|
|
)
|
|
|
|
info, err := os.Stat(path)
|
|
require.NoError(t, err)
|
|
offset := info.Size()
|
|
|
|
// Append UUID-bearing entries that form a linear chain
|
|
// (each entry's parentUuid == previous entry's uuid).
|
|
// This should NOT trigger ErrDAGDetected.
|
|
line1 := `{"type":"user","uuid":"u1",` +
|
|
`"parentUuid":"pre-existing",` +
|
|
`"timestamp":"` + tsEarlyS5 +
|
|
`","message":{"content":"msg1"}}` + "\n"
|
|
line2 := `{"type":"assistant","uuid":"u2",` +
|
|
`"parentUuid":"u1",` +
|
|
`"timestamp":"` + tsLate +
|
|
`","message":{"content":[` +
|
|
`{"type":"text","text":"reply"}]}}` + "\n"
|
|
|
|
f, err := os.OpenFile(
|
|
path, os.O_APPEND|os.O_WRONLY, 0o644,
|
|
)
|
|
require.NoError(t, err)
|
|
_, err = f.WriteString(line1 + line2)
|
|
require.NoError(t, err)
|
|
require.NoError(t, f.Close())
|
|
|
|
newMsgs, endedAt, _, err := callParseClaudeSessionFrom(
|
|
path, offset, 1, "",
|
|
)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, 2, len(newMsgs))
|
|
assert.Equal(t, 1, newMsgs[0].Ordinal)
|
|
assert.Equal(t, 2, newMsgs[1].Ordinal)
|
|
assert.False(t, endedAt.IsZero())
|
|
}
|
|
|
|
// Appended user entry carries toolUseResult.agentId. The incremental
|
|
// parser should return a typed linkage effect while still parsing the
|
|
// appended user message.
|
|
func TestParseClaudeSessionFrom_ToolUseResultAgentIDLinksIncrementally(
|
|
t *testing.T,
|
|
) {
|
|
t.Parallel()
|
|
|
|
initial := testjsonl.JoinJSONL(
|
|
testjsonl.ClaudeUserJSON("hello", tsEarly),
|
|
)
|
|
path := createTestFile(
|
|
t, "inc-tool-result-agentid.jsonl", initial,
|
|
)
|
|
|
|
info, err := os.Stat(path)
|
|
require.NoError(t, err)
|
|
offset := info.Size()
|
|
|
|
appended := `{"type":"user","uuid":"u1",` +
|
|
`"parentUuid":"pre",` +
|
|
`"timestamp":"` + tsEarlyS5 +
|
|
`","message":{"content":[` +
|
|
`{"type":"tool_result","tool_use_id":"toolu_x",` +
|
|
`"content":"done"}]},` +
|
|
`"toolUseResult":{"status":"completed",` +
|
|
`"agentId":"abc123"}}` + "\n"
|
|
|
|
f, err := os.OpenFile(
|
|
path, os.O_APPEND|os.O_WRONLY, 0o644,
|
|
)
|
|
require.NoError(t, err)
|
|
_, err = f.WriteString(appended)
|
|
require.NoError(t, err)
|
|
require.NoError(t, f.Close())
|
|
|
|
newMsgs, links, _, _, err := callParseClaudeSessionFromWithLinks(
|
|
path, offset, 1, "",
|
|
)
|
|
require.NoError(t, err)
|
|
assert.Len(t, newMsgs, 1)
|
|
require.Len(t, links, 1)
|
|
assert.Equal(t, "toolu_x", links[0].ToolUseID)
|
|
assert.Equal(t, "agent-abc123", links[0].SubagentSessionID)
|
|
}
|
|
|
|
func TestParseClaudeSession_ResolvesPersistedToolResultOutput(
|
|
t *testing.T,
|
|
) {
|
|
t.Parallel()
|
|
|
|
dir := t.TempDir()
|
|
sessionDir := filepath.Join(dir, "project", "parent-session")
|
|
resultPath := filepath.Join(sessionDir, "tool-results", "b123.txt")
|
|
require.NoError(t, os.MkdirAll(filepath.Dir(resultPath), 0o755))
|
|
|
|
fullOutput := "full output line 1\nfull output line 2\n"
|
|
require.NoError(t, os.WriteFile(resultPath, []byte(fullOutput), 0o644))
|
|
|
|
resultPathJSON := mustJSONString(t, resultPath)
|
|
persistedContentJSON := mustJSONString(t,
|
|
"<persisted-output>\n"+
|
|
"Output too large (32B). Full output saved to: "+resultPath+
|
|
"\n\nPreview (first 2KB):\npreview only\n</persisted-output>")
|
|
|
|
content := strings.Join([]string{
|
|
`{"type":"user","timestamp":"2024-01-01T00:00:00Z","uuid":"u1","message":{"content":"run it"},"cwd":"/tmp/project"}`,
|
|
`{"type":"assistant","timestamp":"2024-01-01T00:00:01Z","uuid":"a1","parentUuid":"u1","message":{"content":[{"type":"tool_use","id":"toolu_1","name":"Bash","input":{"command":"make logs"}}]}}`,
|
|
`{"type":"user","timestamp":"2024-01-01T00:00:02Z","uuid":"u2","parentUuid":"a1","message":{"content":[{"type":"tool_result","tool_use_id":"toolu_1","content":` + persistedContentJSON + `,"is_error":false}]},"toolUseResult":{"persistedOutputPath":` + resultPathJSON + `,"persistedOutputSize":32}}`,
|
|
}, "\n")
|
|
sessionPath := filepath.Join(dir, "project", "parent-session.jsonl")
|
|
require.NoError(t, os.WriteFile(sessionPath, []byte(content), 0o644))
|
|
|
|
results, err := parseClaudeSession(sessionPath, "project", "local")
|
|
require.NoError(t, err)
|
|
require.Len(t, results, 1)
|
|
require.Len(t, results[0].Messages, 3)
|
|
require.Len(t, results[0].Messages[2].ToolResults, 1)
|
|
|
|
got := results[0].Messages[2].ToolResults[0]
|
|
assert.Equal(t, len(fullOutput), got.ContentLength)
|
|
assert.Equal(t, fullOutput, DecodeContent(got.ContentRaw))
|
|
}
|
|
|
|
func TestParseClaudeSession_PersistedToolResultDoesNotOverwriteSiblings(
|
|
t *testing.T,
|
|
) {
|
|
t.Parallel()
|
|
|
|
dir := t.TempDir()
|
|
sessionDir := filepath.Join(dir, "project", "parent-session")
|
|
resultPath := filepath.Join(sessionDir, "tool-results", "b123.txt")
|
|
require.NoError(t, os.MkdirAll(filepath.Dir(resultPath), 0o755))
|
|
|
|
fullOutput := "full persisted output\n"
|
|
require.NoError(t, os.WriteFile(resultPath, []byte(fullOutput), 0o644))
|
|
|
|
resultPathJSON := mustJSONString(t, resultPath)
|
|
persistedContentJSON := mustJSONString(t,
|
|
"<persisted-output>\n"+
|
|
"Output too large. Full output saved to: "+resultPath+
|
|
"\n\nPreview (first 2KB):\npreview only\n</persisted-output>")
|
|
|
|
content := strings.Join([]string{
|
|
`{"type":"user","timestamp":"2024-01-01T00:00:00Z","uuid":"u1","message":{"content":"run it"},"cwd":"/tmp/project"}`,
|
|
`{"type":"assistant","timestamp":"2024-01-01T00:00:01Z","uuid":"a1","parentUuid":"u1","message":{"content":[{"type":"tool_use","id":"toolu_big","name":"Bash","input":{"command":"make logs"}},{"type":"tool_use","id":"toolu_small","name":"Read","input":{"file_path":"README.md"}}]}}`,
|
|
`{"type":"user","timestamp":"2024-01-01T00:00:02Z","uuid":"u2","parentUuid":"a1","message":{"content":[{"type":"tool_result","tool_use_id":"toolu_big","content":` + persistedContentJSON + `,"is_error":false},{"type":"tool_result","tool_use_id":"toolu_small","content":"small inline result","is_error":false}]},"toolUseResult":{"persistedOutputPath":` + resultPathJSON + `,"persistedOutputSize":22}}`,
|
|
}, "\n")
|
|
sessionPath := filepath.Join(dir, "project", "parent-session.jsonl")
|
|
require.NoError(t, os.WriteFile(sessionPath, []byte(content), 0o644))
|
|
|
|
results, err := parseClaudeSession(sessionPath, "project", "local")
|
|
require.NoError(t, err)
|
|
require.Len(t, results, 1)
|
|
require.Len(t, results[0].Messages, 3)
|
|
require.Len(t, results[0].Messages[2].ToolResults, 2)
|
|
|
|
toolResults := results[0].Messages[2].ToolResults
|
|
assert.Equal(t, fullOutput, DecodeContent(toolResults[0].ContentRaw))
|
|
assert.Equal(t, "small inline result", DecodeContent(toolResults[1].ContentRaw))
|
|
assert.Equal(t, len("small inline result"), toolResults[1].ContentLength)
|
|
}
|
|
|
|
func mustJSONString(t *testing.T, value string) string {
|
|
t.Helper()
|
|
encoded, err := json.Marshal(value)
|
|
require.NoError(t, err)
|
|
return string(encoded)
|
|
}
|
|
|
|
// Two appended assistant entries with the same message.id form a
|
|
// run that the full parser merges into one message; the incremental
|
|
// path would otherwise produce two separate stored messages, so it
|
|
// must signal full-parse fallback.
|
|
func TestParseClaudeSessionFrom_SameMessageIDFallsBack(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
initial := testjsonl.JoinJSONL(
|
|
testjsonl.ClaudeUserJSON("hello", tsEarly),
|
|
)
|
|
path := createTestFile(
|
|
t, "inc-same-msgid.jsonl", initial,
|
|
)
|
|
|
|
info, err := os.Stat(path)
|
|
require.NoError(t, err)
|
|
offset := info.Size()
|
|
|
|
a1 := `{"type":"assistant","uuid":"a1",` +
|
|
`"parentUuid":"pre",` +
|
|
`"timestamp":"` + tsEarlyS5 +
|
|
`","message":{"id":"msg_run","content":[` +
|
|
`{"type":"text","text":"Hi"}]}}` + "\n"
|
|
a2 := `{"type":"assistant","uuid":"a2",` +
|
|
`"parentUuid":"a1",` +
|
|
`"timestamp":"` + tsLate +
|
|
`","message":{"id":"msg_run","content":[` +
|
|
`{"type":"text","text":"Hi there"}]}}` + "\n"
|
|
|
|
f, err := os.OpenFile(
|
|
path, os.O_APPEND|os.O_WRONLY, 0o644,
|
|
)
|
|
require.NoError(t, err)
|
|
_, err = f.WriteString(a1 + a2)
|
|
require.NoError(t, err)
|
|
require.NoError(t, f.Close())
|
|
|
|
_, _, _, err = callParseClaudeSessionFrom(path, offset, 1, "")
|
|
assert.ErrorIs(t, err, ErrClaudeIncrementalNeedsFullParse)
|
|
assert.True(t, IsIncrementalFullParseFallback(err))
|
|
}
|
|
|
|
func TestParseClaudeSessionFrom_QueueOperationOnlyFallsBack(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
initial := testjsonl.JoinJSONL(
|
|
`{"type":"user","uuid":"u1","timestamp":"`+tsEarly+`","message":{"content":"go"}}`,
|
|
`{"type":"assistant","uuid":"a1","parentUuid":"u1","timestamp":"`+tsEarlyS5+`","message":{"id":"msg_queue","content":[{"type":"tool_use","id":"toolu_queue","name":"Agent","input":{"description":"inspect","subagent_type":"Explore","prompt":"inspect"}}]}}`,
|
|
)
|
|
path := createTestFile(
|
|
t, "inc-queue-only.jsonl", initial,
|
|
)
|
|
|
|
info, err := os.Stat(path)
|
|
require.NoError(t, err)
|
|
offset := info.Size()
|
|
|
|
appended := `{"type":"queue-operation","operation":"enqueue","timestamp":"` +
|
|
tsLate + `","sessionId":"queued-subagent","content":"{\"task_id\":\"childqueue\",\"tool_use_id\":\"toolu_queue\",\"description\":\"inspect\",\"task_type\":\"local_agent\"}"}` + "\n"
|
|
|
|
f, err := os.OpenFile(
|
|
path, os.O_APPEND|os.O_WRONLY, 0o644,
|
|
)
|
|
require.NoError(t, err)
|
|
_, err = f.WriteString(appended)
|
|
require.NoError(t, err)
|
|
require.NoError(t, f.Close())
|
|
|
|
_, _, _, err = callParseClaudeSessionFrom(path, offset, 2, "a1")
|
|
assert.ErrorIs(t, err, ErrClaudeIncrementalNeedsFullParse)
|
|
assert.True(t, IsIncrementalFullParseFallback(err))
|
|
}
|
|
|
|
func TestParseClaudeSessionFrom_ProgressOnlyFallsBack(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
initial := testjsonl.JoinJSONL(
|
|
`{"type":"user","uuid":"u1","timestamp":"`+tsEarly+`","message":{"content":"go"}}`,
|
|
`{"type":"assistant","uuid":"a1","parentUuid":"u1","timestamp":"`+tsEarlyS5+`","message":{"id":"msg_progress","content":[{"type":"tool_use","id":"toolu_progress","name":"Agent","input":{"description":"inspect","subagent_type":"Explore","prompt":"inspect"}}]}}`,
|
|
)
|
|
path := createTestFile(
|
|
t, "inc-progress-only.jsonl", initial,
|
|
)
|
|
|
|
info, err := os.Stat(path)
|
|
require.NoError(t, err)
|
|
offset := info.Size()
|
|
|
|
appended := `{"type":"progress","timestamp":"` + tsLate +
|
|
`","parentToolUseID":"toolu_progress","data":{"type":"agent_progress","agentId":"childprogress"}}` + "\n"
|
|
|
|
f, err := os.OpenFile(
|
|
path, os.O_APPEND|os.O_WRONLY, 0o644,
|
|
)
|
|
require.NoError(t, err)
|
|
_, err = f.WriteString(appended)
|
|
require.NoError(t, err)
|
|
require.NoError(t, f.Close())
|
|
|
|
_, _, _, err = callParseClaudeSessionFrom(path, offset, 2, "a1")
|
|
assert.ErrorIs(t, err, ErrClaudeIncrementalNeedsFullParse)
|
|
assert.True(t, IsIncrementalFullParseFallback(err))
|
|
}
|
|
|
|
// Sanity: a benign incremental append (one user, one assistant
|
|
// with a unique message.id, no toolUseResult.agentId) must NOT
|
|
// trigger fallback.
|
|
func TestParseClaudeSessionFrom_BenignAppendNoFallback(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
initial := testjsonl.JoinJSONL(
|
|
testjsonl.ClaudeUserJSON("hello", tsEarly),
|
|
)
|
|
path := createTestFile(
|
|
t, "inc-benign.jsonl", initial,
|
|
)
|
|
|
|
info, err := os.Stat(path)
|
|
require.NoError(t, err)
|
|
offset := info.Size()
|
|
|
|
u := `{"type":"user","uuid":"u1",` +
|
|
`"parentUuid":"pre",` +
|
|
`"timestamp":"` + tsEarlyS5 +
|
|
`","message":{"content":"more input"}}` + "\n"
|
|
a := `{"type":"assistant","uuid":"a1","parentUuid":"u1",` +
|
|
`"timestamp":"` + tsLate +
|
|
`","message":{"id":"msg_unique","content":[` +
|
|
`{"type":"text","text":"reply"}]}}` + "\n"
|
|
|
|
f, err := os.OpenFile(
|
|
path, os.O_APPEND|os.O_WRONLY, 0o644,
|
|
)
|
|
require.NoError(t, err)
|
|
_, err = f.WriteString(u + a)
|
|
require.NoError(t, err)
|
|
require.NoError(t, f.Close())
|
|
|
|
newMsgs, _, _, err := callParseClaudeSessionFrom(path, offset, 1, "")
|
|
require.NoError(t, err)
|
|
assert.Len(t, newMsgs, 2)
|
|
}
|
|
|
|
func TestParseClaudeSession_TerminationStatus(t *testing.T) {
|
|
t.Run("clean", func(t *testing.T) {
|
|
content := loadFixture(t, "claude/valid_session.jsonl")
|
|
sess, _ := runClaudeParserTest(t, "test.jsonl", content)
|
|
assert.Equal(t, TerminationClean, sess.TerminationStatus)
|
|
})
|
|
|
|
t.Run("tool_call_pending", func(t *testing.T) {
|
|
content := loadFixture(t, "claude/tool_call_pending.jsonl")
|
|
sess, _ := runClaudeParserTest(t, "test.jsonl", content)
|
|
assert.Equal(t, TerminationToolCallPending, sess.TerminationStatus)
|
|
})
|
|
|
|
t.Run("truncated", func(t *testing.T) {
|
|
content := loadFixture(t, "claude/truncated.jsonl")
|
|
sess, _ := runClaudeParserTest(t, "test.jsonl", content)
|
|
assert.Equal(t, TerminationTruncated, sess.TerminationStatus)
|
|
})
|
|
}
|
|
|
|
func TestParseClaudeSession_TokenUsage(t *testing.T) {
|
|
t.Run("explicit parser presence beats fallback inference", func(t *testing.T) {
|
|
msg := ParsedMessage{
|
|
TokenUsage: json.RawMessage(`{"input_tokens":100,"output_tokens":50}`),
|
|
tokenPresenceKnown: true,
|
|
}
|
|
msgHasCtx, msgHasOut := msg.TokenPresence()
|
|
assert.False(t, msgHasCtx)
|
|
assert.False(t, msgHasOut)
|
|
|
|
sess := ParsedSession{
|
|
TotalOutputTokens: 50,
|
|
PeakContextTokens: 100,
|
|
aggregateTokenPresenceKnown: true,
|
|
}
|
|
sessHasTotal, sessHasPeak := sess.AggregateTokenPresence()
|
|
assert.False(t, sessHasTotal)
|
|
assert.False(t, sessHasPeak)
|
|
})
|
|
|
|
t.Run("per-message token fields from fixture", func(t *testing.T) {
|
|
content := loadFixture(t, "claude/valid_session.jsonl")
|
|
_, msgs := runClaudeParserTest(t, "test.jsonl", content)
|
|
|
|
// msgs[0] is user (no usage), msgs[1] is assistant (has usage),
|
|
// msgs[2] is user (no usage), msgs[3] is assistant (has usage).
|
|
assert.Equal(t, 0, msgs[0].ContextTokens)
|
|
assert.Equal(t, 0, msgs[0].OutputTokens)
|
|
assert.False(t, msgs[0].HasContextTokens)
|
|
assert.False(t, msgs[0].HasOutputTokens)
|
|
assert.Empty(t, msgs[0].Model)
|
|
assert.Empty(t, msgs[0].TokenUsage)
|
|
|
|
// input=100, cache_creation=200, cache_read=300 -> context=600
|
|
assert.Equal(t, 600, msgs[1].ContextTokens)
|
|
assert.Equal(t, 50, msgs[1].OutputTokens)
|
|
assert.True(t, msgs[1].HasContextTokens)
|
|
assert.True(t, msgs[1].HasOutputTokens)
|
|
assert.Equal(t, "claude-sonnet-4-20250514", msgs[1].Model)
|
|
assert.Contains(t, string(msgs[1].TokenUsage), `"input_tokens":100`)
|
|
|
|
assert.Equal(t, 0, msgs[2].ContextTokens)
|
|
assert.Equal(t, 0, msgs[2].OutputTokens)
|
|
assert.False(t, msgs[2].HasContextTokens)
|
|
assert.False(t, msgs[2].HasOutputTokens)
|
|
|
|
// input=150, cache_creation=0, cache_read=500 -> context=650
|
|
assert.Equal(t, 650, msgs[3].ContextTokens)
|
|
assert.Equal(t, 75, msgs[3].OutputTokens)
|
|
assert.True(t, msgs[3].HasContextTokens)
|
|
assert.True(t, msgs[3].HasOutputTokens)
|
|
assert.Equal(t, "claude-sonnet-4-20250514", msgs[3].Model)
|
|
assert.Contains(t, string(msgs[3].TokenUsage), `"input_tokens":150`)
|
|
})
|
|
|
|
t.Run("session totals from fixture", func(t *testing.T) {
|
|
content := loadFixture(t, "claude/valid_session.jsonl")
|
|
sess, _ := runClaudeParserTest(t, "test.jsonl", content)
|
|
|
|
assert.Equal(t, 125, sess.TotalOutputTokens)
|
|
assert.Equal(t, 650, sess.PeakContextTokens)
|
|
assert.True(t, sess.HasTotalOutputTokens)
|
|
assert.True(t, sess.HasPeakContextTokens)
|
|
})
|
|
|
|
t.Run("messages without usage get zero values", func(t *testing.T) {
|
|
content := testjsonl.JoinJSONL(
|
|
testjsonl.ClaudeUserJSON("hello", tsZero),
|
|
testjsonl.ClaudeAssistantJSON([]map[string]any{
|
|
{"type": "text", "text": "hi there"},
|
|
}, tsZeroS1),
|
|
)
|
|
sess, msgs := runClaudeParserTest(t, "test.jsonl", content)
|
|
|
|
assert.Equal(t, 0, msgs[0].ContextTokens)
|
|
assert.Equal(t, 0, msgs[1].ContextTokens)
|
|
assert.Equal(t, 0, msgs[1].OutputTokens)
|
|
assert.False(t, msgs[0].HasContextTokens)
|
|
assert.False(t, msgs[0].HasOutputTokens)
|
|
assert.False(t, msgs[1].HasContextTokens)
|
|
assert.False(t, msgs[1].HasOutputTokens)
|
|
assert.Empty(t, msgs[1].TokenUsage)
|
|
|
|
assert.Equal(t, 0, sess.TotalOutputTokens)
|
|
assert.Equal(t, 0, sess.PeakContextTokens)
|
|
assert.False(t, sess.HasTotalOutputTokens)
|
|
assert.False(t, sess.HasPeakContextTokens)
|
|
})
|
|
|
|
t.Run("zero-valued usage keys preserve coverage", func(t *testing.T) {
|
|
content := testjsonl.JoinJSONL(
|
|
testjsonl.ClaudeUserJSON("hello", tsZero),
|
|
`{"type":"assistant","timestamp":"`+tsZeroS1+`","message":{"model":"claude-sonnet-4-20250514","content":[{"type":"text","text":"still counted"}],"usage":{"input_tokens":0,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"output_tokens":0}}}`,
|
|
)
|
|
sess, msgs := runClaudeParserTest(t, "test.jsonl", content)
|
|
|
|
require.Equal(t, 2, len(msgs))
|
|
assert.Equal(t, 0, msgs[1].ContextTokens)
|
|
assert.Equal(t, 0, msgs[1].OutputTokens)
|
|
assert.True(t, msgs[1].HasContextTokens)
|
|
assert.True(t, msgs[1].HasOutputTokens)
|
|
msgHasCtx, msgHasOut := msgs[1].TokenPresence()
|
|
assert.True(t, msgHasCtx)
|
|
assert.True(t, msgHasOut)
|
|
|
|
assert.Equal(t, 0, sess.TotalOutputTokens)
|
|
assert.Equal(t, 0, sess.PeakContextTokens)
|
|
assert.True(t, sess.HasTotalOutputTokens)
|
|
assert.True(t, sess.HasPeakContextTokens)
|
|
sessHasTotal, sessHasPeak := sess.AggregateTokenPresence()
|
|
assert.True(t, sessHasTotal)
|
|
assert.True(t, sessHasPeak)
|
|
coverageTotal, coveragePeak := sess.TokenCoverage(msgs)
|
|
assert.True(t, coverageTotal)
|
|
assert.True(t, coveragePeak)
|
|
})
|
|
}
|
|
|
|
func loadFixture(t *testing.T, name string) string {
|
|
t.Helper()
|
|
path := filepath.Join("testdata", name)
|
|
data, err := os.ReadFile(path)
|
|
require.NoError(t, err)
|
|
return string(data)
|
|
}
|
|
|
|
func TestTruncateRespectsRuneBoundaries(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
maxLen int
|
|
want string
|
|
}{
|
|
{
|
|
name: "ASCII within limit",
|
|
input: "hello",
|
|
maxLen: 10,
|
|
want: "hello",
|
|
},
|
|
{
|
|
name: "ASCII truncated",
|
|
input: "hello world",
|
|
maxLen: 5,
|
|
want: "hello...",
|
|
},
|
|
{
|
|
name: "multibyte within limit",
|
|
input: "café",
|
|
maxLen: 10,
|
|
want: "café",
|
|
},
|
|
{
|
|
name: "multibyte at boundary",
|
|
// 4 runes: c, a, f, é — truncate at 3 runes
|
|
input: "café",
|
|
maxLen: 3,
|
|
want: "caf...",
|
|
},
|
|
{
|
|
name: "CJK characters",
|
|
// 3 runes, each 3 bytes
|
|
input: "你好世界",
|
|
maxLen: 2,
|
|
want: "你好...",
|
|
},
|
|
{
|
|
name: "ellipsis character preserved",
|
|
// U+2026 is 3 bytes but 1 rune
|
|
input: "abc\u2026def",
|
|
maxLen: 4,
|
|
want: "abc\u2026...",
|
|
},
|
|
}
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got := truncate(tc.input, tc.maxLen)
|
|
if got != tc.want {
|
|
t.Errorf(
|
|
"truncate(%q, %d) = %q, want %q",
|
|
tc.input, tc.maxLen, got, tc.want,
|
|
)
|
|
}
|
|
// Verify result is valid UTF-8.
|
|
if !utf8.ValidString(got) {
|
|
t.Errorf(
|
|
"truncate produced invalid UTF-8: %q",
|
|
got,
|
|
)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestParseClaudeSession_ExtractsMessageIDAndRequestID(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "sess-1.jsonl")
|
|
// Single assistant line with usage + id + requestId.
|
|
line := `{"type":"assistant","uuid":"u1","parentUuid":"",` +
|
|
`"timestamp":"2026-04-10T10:00:00.000Z",` +
|
|
`"requestId":"req_01ABC",` +
|
|
`"message":{"id":"msg_01XYZ","model":"claude-opus-4-6",` +
|
|
`"content":[{"type":"text","text":"hi"}],` +
|
|
`"usage":{"input_tokens":10,"output_tokens":20,` +
|
|
`"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}}`
|
|
if err := os.WriteFile(path, []byte(line+"\n"), 0o644); err != nil {
|
|
t.Fatalf("write fixture: %v", err)
|
|
}
|
|
|
|
results, err := parseClaudeSession(path, "proj", "m")
|
|
if err != nil {
|
|
t.Fatalf("parse: %v", err)
|
|
}
|
|
if len(results) != 1 {
|
|
t.Fatalf("results = %d, want 1", len(results))
|
|
}
|
|
msgs := results[0].Messages
|
|
if len(msgs) != 1 {
|
|
t.Fatalf("messages = %d, want 1", len(msgs))
|
|
}
|
|
m := msgs[0]
|
|
if m.ClaudeMessageID != "msg_01XYZ" {
|
|
t.Errorf("ClaudeMessageID = %q, want msg_01XYZ", m.ClaudeMessageID)
|
|
}
|
|
if m.ClaudeRequestID != "req_01ABC" {
|
|
t.Errorf("ClaudeRequestID = %q, want req_01ABC", m.ClaudeRequestID)
|
|
}
|
|
if m.OutputTokens != 20 {
|
|
t.Errorf("OutputTokens = %d, want 20", m.OutputTokens)
|
|
}
|
|
}
|
|
|
|
func TestParseClaudeSession_SameMessageIDStreamingSnapshots(t *testing.T) {
|
|
lines := []string{
|
|
`{"type":"user","timestamp":"2024-01-01T10:00:00Z","uuid":"u1","message":{"content":"hello"},"cwd":"/tmp"}`,
|
|
`{"type":"assistant","timestamp":"2024-01-01T10:00:01Z","uuid":"a1","parentUuid":"u1","message":{"id":"msg_stream","content":[{"type":"text","text":"Work"}],"usage":{"input_tokens":1,"output_tokens":1},"stop_reason":"tool_use"}}`,
|
|
`{"type":"assistant","timestamp":"2024-01-01T10:00:02Z","uuid":"a2","parentUuid":"a1","message":{"id":"msg_stream","content":[{"type":"text","text":"Working"},{"type":"tool_use","id":"toolu_same","name":"Agent","input":{"description":"same","subagent_type":"Explore","prompt":"same"}}],"usage":{"input_tokens":1,"output_tokens":2},"stop_reason":"tool_use"}}`,
|
|
`{"type":"assistant","timestamp":"2024-01-01T10:00:03Z","uuid":"a3","parentUuid":"a2","message":{"id":"msg_stream","content":[{"type":"text","text":"Working"},{"type":"tool_use","id":"toolu_same","name":"Agent","input":{"description":"same","subagent_type":"Explore","prompt":"same"}}],"usage":{"input_tokens":1,"output_tokens":3},"stop_reason":"tool_use"}}`,
|
|
}
|
|
|
|
_, msgs := runClaudeParserTest(
|
|
t, "same-message-streaming.jsonl", testjsonl.JoinJSONL(lines...),
|
|
)
|
|
|
|
require.Len(t, msgs, 2)
|
|
msg := msgs[1]
|
|
assert.Equal(t, "Working\n[Task: same (Explore)]", msg.Content)
|
|
assert.Equal(t, 3, msg.OutputTokens)
|
|
assert.Len(t, msg.ToolCalls, 1)
|
|
assert.Equal(t, "toolu_same", msg.ToolCalls[0].ToolUseID)
|
|
}
|
|
|
|
// Cumulative snapshots where the same tool_use id appears with
|
|
// progressively more complete input JSON across snapshots. Without
|
|
// id-keyed dedup these would each be a distinct raw block and produce
|
|
// duplicate tool calls; the latest snapshot must win.
|
|
func TestParseClaudeSession_SameMessageIDProgressiveToolUseInput(t *testing.T) {
|
|
lines := []string{
|
|
`{"type":"user","timestamp":"2024-01-01T10:00:00Z","uuid":"u1","message":{"content":"hello"},"cwd":"/tmp"}`,
|
|
`{"type":"assistant","timestamp":"2024-01-01T10:00:01Z","uuid":"a1","parentUuid":"u1","message":{"id":"msg_grow","content":[{"type":"text","text":"Work"},{"type":"tool_use","id":"toolu_grow","name":"Agent","input":{"description":"insp"}}],"usage":{"input_tokens":1,"output_tokens":1},"stop_reason":"tool_use"}}`,
|
|
`{"type":"assistant","timestamp":"2024-01-01T10:00:02Z","uuid":"a2","parentUuid":"a1","message":{"id":"msg_grow","content":[{"type":"text","text":"Working"},{"type":"tool_use","id":"toolu_grow","name":"Agent","input":{"description":"inspect","subagent_type":"Explore"}}],"usage":{"input_tokens":1,"output_tokens":2},"stop_reason":"tool_use"}}`,
|
|
`{"type":"assistant","timestamp":"2024-01-01T10:00:03Z","uuid":"a3","parentUuid":"a2","message":{"id":"msg_grow","content":[{"type":"text","text":"Working on it"},{"type":"tool_use","id":"toolu_grow","name":"Agent","input":{"description":"inspect schema","subagent_type":"Explore","prompt":"inspect the schema"}}],"usage":{"input_tokens":1,"output_tokens":3},"stop_reason":"tool_use"}}`,
|
|
}
|
|
|
|
_, msgs := runClaudeParserTest(
|
|
t, "progressive-tool-input.jsonl", testjsonl.JoinJSONL(lines...),
|
|
)
|
|
|
|
require.Len(t, msgs, 2)
|
|
msg := msgs[1]
|
|
require.Len(t, msg.ToolCalls, 1)
|
|
tc := msg.ToolCalls[0]
|
|
assert.Equal(t, "toolu_grow", tc.ToolUseID)
|
|
assert.JSONEq(t, `{"description":"inspect schema","subagent_type":"Explore","prompt":"inspect the schema"}`, tc.InputJSON)
|
|
assert.Equal(t, 3, msg.OutputTokens)
|
|
assert.Contains(t, msg.Content, "Working on it")
|
|
}
|
|
|
|
// Additive same-message.id chunks where each chunk has one distinct
|
|
// text block. Ordinal-only matching would have collapsed all three
|
|
// to whichever was longest; cumulative-vs-additive detection sees
|
|
// that each chunk fails the leading-block alignment check and
|
|
// appends them as distinct content.
|
|
func TestParseClaudeSession_SameMessageIDAdditiveDistinctTextBlocks(t *testing.T) {
|
|
lines := []string{
|
|
`{"type":"user","timestamp":"2024-01-01T10:00:00Z","uuid":"u1","message":{"content":"hello"},"cwd":"/tmp"}`,
|
|
`{"type":"assistant","timestamp":"2024-01-01T10:00:01Z","uuid":"a1","parentUuid":"u1","message":{"id":"msg_add","content":[{"type":"text","text":"First sentence."}],"usage":{"input_tokens":1,"output_tokens":1},"stop_reason":"end_turn"}}`,
|
|
`{"type":"assistant","timestamp":"2024-01-01T10:00:02Z","uuid":"a2","parentUuid":"a1","message":{"id":"msg_add","content":[{"type":"text","text":"Second sentence."}],"usage":{"input_tokens":1,"output_tokens":2},"stop_reason":"end_turn"}}`,
|
|
`{"type":"assistant","timestamp":"2024-01-01T10:00:03Z","uuid":"a3","parentUuid":"a2","message":{"id":"msg_add","content":[{"type":"text","text":"Third sentence."}],"usage":{"input_tokens":1,"output_tokens":3},"stop_reason":"end_turn"}}`,
|
|
}
|
|
|
|
_, msgs := runClaudeParserTest(
|
|
t, "additive-distinct-text.jsonl", testjsonl.JoinJSONL(lines...),
|
|
)
|
|
|
|
require.Len(t, msgs, 2)
|
|
msg := msgs[1]
|
|
require.Equal(t, RoleAssistant, msg.Role)
|
|
assert.Contains(t, msg.Content, "First sentence.")
|
|
assert.Contains(t, msg.Content, "Second sentence.")
|
|
assert.Contains(t, msg.Content, "Third sentence.")
|
|
}
|
|
|
|
// Two single-text-block additive chunks where the second's text
|
|
// begins byte-for-byte with the first AND the first chunk's
|
|
// stop_reason="end_turn" marks the message as already terminated.
|
|
// Subsequent same-message.id chunks are additive rather than
|
|
// streaming snapshots, so the prefix relationship must NOT be
|
|
// classified as cumulative growth.
|
|
func TestParseClaudeSession_SameMessageIDAdditivePrefixCollidingSingleBlocks(t *testing.T) {
|
|
lines := []string{
|
|
`{"type":"user","timestamp":"2024-01-01T10:00:00Z","uuid":"u1","message":{"content":"hello"},"cwd":"/tmp"}`,
|
|
`{"type":"assistant","timestamp":"2024-01-01T10:00:01Z","uuid":"a1","parentUuid":"u1","message":{"id":"msg_pref","content":[{"type":"text","text":"First."}],"usage":{"input_tokens":1,"output_tokens":1},"stop_reason":"end_turn"}}`,
|
|
`{"type":"assistant","timestamp":"2024-01-01T10:00:02Z","uuid":"a2","parentUuid":"a1","message":{"id":"msg_pref","content":[{"type":"text","text":"First. Continued."}],"usage":{"input_tokens":1,"output_tokens":2},"stop_reason":"end_turn"}}`,
|
|
}
|
|
|
|
_, msgs := runClaudeParserTest(
|
|
t, "additive-prefix-single.jsonl", testjsonl.JoinJSONL(lines...),
|
|
)
|
|
|
|
require.Len(t, msgs, 2)
|
|
msg := msgs[1]
|
|
require.Equal(t, RoleAssistant, msg.Role)
|
|
assert.Contains(t, msg.Content, "First.")
|
|
assert.Contains(t, msg.Content, "First. Continued.")
|
|
}
|
|
|
|
// Two single-text-block snapshots where the second extends the
|
|
// first as a byte-for-byte prefix and neither has finalized the
|
|
// message (no stop_reason="end_turn" in the first snapshot). The
|
|
// merge must treat this as cumulative streaming and REPLACE the
|
|
// partial snapshot — not concatenate as additive content.
|
|
func TestParseClaudeSession_SameMessageIDStreamingSingleBlockReplaces(t *testing.T) {
|
|
lines := []string{
|
|
`{"type":"user","timestamp":"2024-01-01T10:00:00Z","uuid":"u1","message":{"content":"hello"},"cwd":"/tmp"}`,
|
|
`{"type":"assistant","timestamp":"2024-01-01T10:00:01Z","uuid":"a1","parentUuid":"u1","message":{"id":"msg_partial","content":[{"type":"text","text":"Hello"}],"usage":{"input_tokens":1,"output_tokens":1},"stop_reason":"tool_use"}}`,
|
|
`{"type":"assistant","timestamp":"2024-01-01T10:00:02Z","uuid":"a2","parentUuid":"a1","message":{"id":"msg_partial","content":[{"type":"text","text":"Hello world"}],"usage":{"input_tokens":1,"output_tokens":2},"stop_reason":"end_turn"}}`,
|
|
}
|
|
|
|
_, msgs := runClaudeParserTest(
|
|
t, "streaming-single-block.jsonl", testjsonl.JoinJSONL(lines...),
|
|
)
|
|
|
|
require.Len(t, msgs, 2)
|
|
msg := msgs[1]
|
|
require.Equal(t, RoleAssistant, msg.Role)
|
|
// Exact equality: the partial "Hello" must be replaced by the
|
|
// final "Hello world", not joined as "Hello\nHello world".
|
|
assert.Equal(t, "Hello world", msg.Content)
|
|
}
|
|
|
|
// Same-message.id chunk merging round-trips the base entry's JSON
|
|
// through Unmarshal/Marshal to swap in the merged content blocks.
|
|
// json.Unmarshal decodes numbers as float64 by default, which
|
|
// truncates integers larger than 2^53. Use json.Decoder.UseNumber
|
|
// to preserve the raw textual form so large integer fields (e.g.
|
|
// future large token-count metrics) survive the merge.
|
|
func TestParseClaudeSession_SameMessageIDPreservesLargeIntegers(t *testing.T) {
|
|
// 9999999999999999 > 2^53 — float64 cannot represent it exactly.
|
|
const bigInt = "9999999999999999"
|
|
lines := []string{
|
|
`{"type":"user","timestamp":"2024-01-01T10:00:00Z","uuid":"u1","message":{"content":"hello"},"cwd":"/tmp"}`,
|
|
`{"type":"assistant","timestamp":"2024-01-01T10:00:01Z","uuid":"a1","parentUuid":"u1","message":{"id":"msg_big","content":[{"type":"text","text":"Hi"}],"usage":{"input_tokens":1,"output_tokens":1,"cache_creation_input_tokens":` + bigInt + `},"stop_reason":"tool_use"}}`,
|
|
`{"type":"assistant","timestamp":"2024-01-01T10:00:02Z","uuid":"a2","parentUuid":"a1","message":{"id":"msg_big","content":[{"type":"text","text":"Hi there"}],"usage":{"input_tokens":1,"output_tokens":2,"cache_creation_input_tokens":` + bigInt + `},"stop_reason":"end_turn"}}`,
|
|
}
|
|
|
|
_, msgs := runClaudeParserTest(
|
|
t, "big-int.jsonl", testjsonl.JoinJSONL(lines...),
|
|
)
|
|
require.Len(t, msgs, 2)
|
|
assert.Contains(
|
|
t, string(msgs[1].TokenUsage), bigInt,
|
|
"large integer in usage must survive the merge "+
|
|
"round-trip; got %s", string(msgs[1].TokenUsage),
|
|
)
|
|
}
|
|
|
|
// Cumulative streaming snapshots of one response that contains
|
|
// text + tool_use + text. The third block in the second snapshot
|
|
// happens to start with the first text block's exact bytes. Snapshot-
|
|
// level cumulative detection keeps both text blocks separately; the
|
|
// prior prefix heuristic would have collapsed the pre-tool intro into
|
|
// the post-tool follow-up.
|
|
func TestParseClaudeSession_SameMessageIDPrefixCollidingTextBlocks(t *testing.T) {
|
|
lines := []string{
|
|
`{"type":"user","timestamp":"2024-01-01T10:00:00Z","uuid":"u1","message":{"content":"hello"},"cwd":"/tmp"}`,
|
|
`{"type":"assistant","timestamp":"2024-01-01T10:00:01Z","uuid":"a1","parentUuid":"u1","message":{"id":"msg_prefix","content":[{"type":"text","text":"Looking at this"},{"type":"tool_use","id":"toolu_one","name":"Read","input":{"file_path":"/tmp/foo.txt"}}],"usage":{"input_tokens":1,"output_tokens":1},"stop_reason":"tool_use"}}`,
|
|
`{"type":"assistant","timestamp":"2024-01-01T10:00:02Z","uuid":"a2","parentUuid":"a1","message":{"id":"msg_prefix","content":[{"type":"text","text":"Looking at this"},{"type":"tool_use","id":"toolu_one","name":"Read","input":{"file_path":"/tmp/foo.txt"}},{"type":"text","text":"Looking at this code carefully."}],"usage":{"input_tokens":1,"output_tokens":2},"stop_reason":"end_turn"}}`,
|
|
}
|
|
|
|
_, msgs := runClaudeParserTest(
|
|
t, "prefix-colliding-text.jsonl", testjsonl.JoinJSONL(lines...),
|
|
)
|
|
|
|
require.Len(t, msgs, 2)
|
|
msg := msgs[1]
|
|
require.Equal(t, RoleAssistant, msg.Role)
|
|
// Both distinct text blocks must survive — the second is
|
|
// post-tool follow-up, not a streaming continuation of the first.
|
|
assert.Equal(t, 2, strings.Count(msg.Content, "Looking at this"),
|
|
"both distinct text blocks should appear in merged content; got %q",
|
|
msg.Content)
|
|
assert.Contains(t, msg.Content, "Looking at this code carefully.")
|
|
require.Len(t, msg.ToolCalls, 1)
|
|
assert.Equal(t, "toolu_one", msg.ToolCalls[0].ToolUseID)
|
|
}
|
|
|
|
func TestParseClaudeSession_CompactBoundary(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
compactEntry := `{"type":"user","isCompactSummary":true,` +
|
|
`"message":{"role":"user","content":[` +
|
|
`{"type":"text","text":"Summary of conversation so far..."},` +
|
|
`{"type":"text","text":"Additional context."}` +
|
|
`]},"uuid":"compact-uuid","parentUuid":"parent-uuid",` +
|
|
`"isSidechain":true,"timestamp":"` + tsZeroS1 + `"}`
|
|
|
|
t.Run("linear path", func(t *testing.T) {
|
|
t.Parallel()
|
|
content := testjsonl.JoinJSONL(
|
|
testjsonl.ClaudeUserJSON("hello", tsZero),
|
|
compactEntry,
|
|
testjsonl.ClaudeUserJSON("after compact", tsZeroS2),
|
|
)
|
|
sess, msgs := runClaudeParserTest(
|
|
t, "test.jsonl", content,
|
|
)
|
|
|
|
require.Len(t, msgs, 3)
|
|
assert.Equal(t, 3, sess.MessageCount)
|
|
// Only real user messages count.
|
|
assert.Equal(t, 2, sess.UserMessageCount)
|
|
assert.Equal(t, "hello", sess.FirstMessage)
|
|
|
|
// Verify compact boundary at ordinal 1.
|
|
cb := msgs[1]
|
|
assert.Equal(t, 1, cb.Ordinal)
|
|
assert.Equal(t, RoleAssistant, cb.Role)
|
|
assert.True(t, cb.IsSystem)
|
|
assert.True(t, cb.IsCompactBoundary)
|
|
assert.Equal(t, "system", cb.SourceType)
|
|
assert.Equal(t, "compact_boundary", cb.SourceSubtype)
|
|
assert.Equal(t, "compact-uuid", cb.SourceUUID)
|
|
assert.Equal(t, "parent-uuid", cb.SourceParentUUID)
|
|
assert.True(t, cb.IsSidechain)
|
|
assert.Equal(
|
|
t,
|
|
"Summary of conversation so far...\n"+
|
|
"Additional context.",
|
|
cb.Content,
|
|
)
|
|
assert.Equal(t, len(cb.Content), cb.ContentLength)
|
|
|
|
// Following message has ordinal 2.
|
|
assert.Equal(t, 2, msgs[2].Ordinal)
|
|
assert.Equal(t, RoleUser, msgs[2].Role)
|
|
})
|
|
|
|
t.Run("DAG path", func(t *testing.T) {
|
|
t.Parallel()
|
|
content := testjsonl.JoinJSONL(
|
|
`{"type":"user","uuid":"u1","parentUuid":"",`+
|
|
`"timestamp":"`+tsZero+`",`+
|
|
`"message":{"content":"hello"}}`,
|
|
`{"type":"user","isCompactSummary":true,`+
|
|
`"uuid":"u2","parentUuid":"u1",`+
|
|
`"timestamp":"`+tsZeroS1+`",`+
|
|
`"isSidechain":false,`+
|
|
`"message":{"role":"user","content":[`+
|
|
`{"type":"text","text":"DAG compact summary"}`+
|
|
`]}}`,
|
|
`{"type":"user","uuid":"u3","parentUuid":"u2",`+
|
|
`"timestamp":"`+tsZeroS2+`",`+
|
|
`"message":{"content":"after compact"}}`,
|
|
)
|
|
sess, msgs := runClaudeParserTest(
|
|
t, "test.jsonl", content,
|
|
)
|
|
|
|
require.Len(t, msgs, 3)
|
|
assert.Equal(t, 2, sess.UserMessageCount)
|
|
|
|
cb := msgs[1]
|
|
assert.Equal(t, RoleAssistant, cb.Role)
|
|
assert.True(t, cb.IsSystem)
|
|
assert.True(t, cb.IsCompactBoundary)
|
|
assert.Equal(t, "DAG compact summary", cb.Content)
|
|
assert.Equal(t, "u2", cb.SourceUUID)
|
|
assert.Equal(t, "u1", cb.SourceParentUUID)
|
|
assert.False(t, cb.IsSidechain)
|
|
})
|
|
|
|
t.Run("incremental path", func(t *testing.T) {
|
|
t.Parallel()
|
|
initial := testjsonl.JoinJSONL(
|
|
testjsonl.ClaudeUserJSON("first", tsEarly),
|
|
)
|
|
path := createTestFile(
|
|
t, "inc-compact.jsonl", initial,
|
|
)
|
|
|
|
info, err := os.Stat(path)
|
|
require.NoError(t, err)
|
|
offset := info.Size()
|
|
|
|
// Append compact boundary + real message.
|
|
appended := compactEntry + "\n" +
|
|
testjsonl.ClaudeUserJSON(
|
|
"after compact", tsEarlyS5,
|
|
) + "\n"
|
|
f, err := os.OpenFile(
|
|
path, os.O_APPEND|os.O_WRONLY, 0o644,
|
|
)
|
|
require.NoError(t, err)
|
|
_, err = f.WriteString(appended)
|
|
require.NoError(t, err)
|
|
require.NoError(t, f.Close())
|
|
|
|
newMsgs, _, _, err := callParseClaudeSessionFrom(
|
|
path, offset, 1, "",
|
|
)
|
|
require.NoError(t, err)
|
|
require.Len(t, newMsgs, 2)
|
|
|
|
cb := newMsgs[0]
|
|
assert.Equal(t, 1, cb.Ordinal)
|
|
assert.Equal(t, RoleAssistant, cb.Role)
|
|
assert.True(t, cb.IsSystem)
|
|
assert.True(t, cb.IsCompactBoundary)
|
|
assert.Equal(t, "system", cb.SourceType)
|
|
assert.Equal(t, "compact_boundary", cb.SourceSubtype)
|
|
|
|
assert.Equal(t, 2, newMsgs[1].Ordinal)
|
|
assert.Equal(t, RoleUser, newMsgs[1].Role)
|
|
})
|
|
}
|
|
|
|
func TestExtractTextContent_ReturnsThinkingText(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
t.Run("joins multiple thinking blocks", func(t *testing.T) {
|
|
content := gjson.Parse(`[
|
|
{"type":"thinking","thinking":"first thought"},
|
|
{"type":"text","text":"reply A"},
|
|
{"type":"thinking","thinking":"second thought"},
|
|
{"type":"text","text":"reply B"}
|
|
]`)
|
|
text, thinking, hasThinking, _, _, _ := ExtractTextContent(content)
|
|
assert.True(t, hasThinking)
|
|
assert.Equal(t, "first thought\n\nsecond thought", thinking)
|
|
assert.Contains(t, text, "[Thinking]\nfirst thought\n[/Thinking]")
|
|
assert.Contains(t, text, "reply A")
|
|
})
|
|
|
|
t.Run("skips empty thinking blocks", func(t *testing.T) {
|
|
content := gjson.Parse(`[
|
|
{"type":"thinking","thinking":""},
|
|
{"type":"thinking","thinking":"real thought"}
|
|
]`)
|
|
_, thinking, _, _, _, _ := ExtractTextContent(content)
|
|
assert.Equal(t, "real thought", thinking)
|
|
})
|
|
}
|
|
|
|
func TestClassifyClaudeSystemMessage(t *testing.T) {
|
|
t.Parallel()
|
|
cases := []struct {
|
|
name string
|
|
content string
|
|
expected string
|
|
}{
|
|
{"continuation", "This session is being continued from a previous conversation...", "continuation"},
|
|
{"resume caveat", "<local-command-caveat>Caveat: ...</local-command-caveat>", "resume"},
|
|
{"interrupted", "[Request interrupted by user]", "interrupted"},
|
|
{"task notification", "<task-notification>done</task-notification>", "task_notification"},
|
|
{"stop hook", "Stop hook feedback: ...", "stop_hook"},
|
|
{"bom prefix", "\uFEFF This session is being continued", "continuation"},
|
|
{"non-caveat local-command", "<local-command-stdout>foo</local-command-stdout>", ""},
|
|
{"regular text", "what do you think?", ""},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
got := classifyClaudeSystemMessage(c.content)
|
|
assert.Equal(t, c.expected, got)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestParseClaudeSession_PromotesSystemSubtypes(t *testing.T) {
|
|
t.Parallel()
|
|
content := testjsonl.JoinJSONL(
|
|
testjsonl.ClaudeUserJSON("what time is it?", tsZero),
|
|
testjsonl.ClaudeAssistantJSON([]map[string]any{
|
|
{"type": "text", "text": "it is 3pm"},
|
|
}, tsZeroS1),
|
|
// Promoted: continuation marker on resume.
|
|
testjsonl.ClaudeUserJSON(
|
|
"This session is being continued from a previous conversation.",
|
|
tsZeroS2,
|
|
),
|
|
testjsonl.ClaudeAssistantJSON([]map[string]any{
|
|
{"type": "text", "text": "welcome back"},
|
|
}, "2024-01-01T00:00:03Z"),
|
|
// Promoted: interrupt marker.
|
|
testjsonl.ClaudeUserJSON(
|
|
"[Request interrupted by user]",
|
|
"2024-01-01T00:00:04Z",
|
|
),
|
|
)
|
|
_, msgs := runClaudeParserTest(t, "test.jsonl", content)
|
|
|
|
var systems []ParsedMessage
|
|
for _, m := range msgs {
|
|
if m.IsSystem {
|
|
systems = append(systems, m)
|
|
}
|
|
}
|
|
require.Len(t, systems, 2)
|
|
|
|
assert.Equal(t, "continuation", systems[0].SourceSubtype)
|
|
assert.Equal(t, "system", systems[0].SourceType)
|
|
// Role stays "user" so analytics that key off role alone
|
|
// don't count these markers as assistant replies. The UI uses
|
|
// is_system + source_subtype for routing.
|
|
assert.Equal(t, RoleUser, systems[0].Role)
|
|
assert.Contains(t, systems[0].Content, "This session is being continued")
|
|
|
|
assert.Equal(t, "interrupted", systems[1].SourceSubtype)
|
|
assert.Equal(t, "system", systems[1].SourceType)
|
|
assert.Equal(t, RoleUser, systems[1].Role)
|
|
}
|
|
|
|
func TestIsSkippablePreviewCommand(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
content string
|
|
want bool
|
|
}{
|
|
// Explicit commands still work.
|
|
{"bare /clear", "/clear", true},
|
|
{"bare /effort", "/effort", true},
|
|
{"/clear with trailing space", "/clear ", true},
|
|
{"/clear with args", "/clear foo", true},
|
|
{"/effort with args", "/effort max", true},
|
|
{"surrounded by whitespace", " /clear ", true},
|
|
{"/clear with tab", "/clear\tfoo", true},
|
|
{"/clear with newline", "/clear\nfoo", true},
|
|
// Generic slash commands are now skipped too.
|
|
{"bare /login", "/login", true},
|
|
{"bare /plan", "/plan", true},
|
|
{"/plan with args", "/plan my project", true},
|
|
{"/clearcache", "/clearcache", true},
|
|
{"/effortless", "/effortless", true},
|
|
{"/cleareffort", "/cleareffort", true},
|
|
{"arbitrary /unrelated", "/unrelated", true},
|
|
// Hyphenated and underscored command names are skipped.
|
|
{"/clear-xyz", "/clear-xyz", true},
|
|
{"/roborev-fix", "/roborev-fix", true},
|
|
{"/skill_name", "/skill_name", true},
|
|
{"/roborev-fix with args", "/roborev-fix some args", true},
|
|
// Non-commands are not skipped.
|
|
{"empty string", "", false},
|
|
{"prose containing /clear", "hello /clear", false},
|
|
{"file path reference", "/usr/local/bin gives an error", false},
|
|
{"plain text", "Fix the login bug", false},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got := isSkippablePreviewCommand(tc.content)
|
|
assert.Equal(t, tc.want, got,
|
|
"content=%q", tc.content)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestParseClaudeSession_SkipClearEffortFirstMessage(t *testing.T) {
|
|
t.Run("single /clear followed by real message", func(t *testing.T) {
|
|
content := testjsonl.JoinJSONL(
|
|
testjsonl.ClaudeUserJSON(
|
|
"<command-name>/clear</command-name>",
|
|
tsZero,
|
|
),
|
|
testjsonl.ClaudeUserJSON("Fix the login bug", tsZeroS1),
|
|
testjsonl.ClaudeAssistantJSON([]map[string]any{
|
|
{"type": "text", "text": "ok"},
|
|
}, tsZeroS2),
|
|
)
|
|
sess, _ := runClaudeParserTest(t, "test.jsonl", content)
|
|
assert.Equal(t, "Fix the login bug", sess.FirstMessage)
|
|
assert.Equal(t, 2, sess.UserMessageCount,
|
|
"skipped commands still count as user turns")
|
|
})
|
|
|
|
t.Run("cascade /effort then /clear then real", func(t *testing.T) {
|
|
content := testjsonl.JoinJSONL(
|
|
testjsonl.ClaudeUserJSON(
|
|
"<command-name>/effort</command-name>\n<command-args>max</command-args>",
|
|
tsZero,
|
|
),
|
|
testjsonl.ClaudeUserJSON(
|
|
"<command-name>/clear</command-name>",
|
|
tsZeroS1,
|
|
),
|
|
testjsonl.ClaudeUserJSON("Real question", tsZeroS2),
|
|
)
|
|
sess, _ := runClaudeParserTest(t, "test.jsonl", content)
|
|
assert.Equal(t, "Real question", sess.FirstMessage)
|
|
assert.Equal(t, 3, sess.UserMessageCount)
|
|
})
|
|
|
|
t.Run("all messages are skipped commands", func(t *testing.T) {
|
|
content := testjsonl.JoinJSONL(
|
|
testjsonl.ClaudeUserJSON(
|
|
"<command-name>/clear</command-name>",
|
|
tsZero,
|
|
),
|
|
testjsonl.ClaudeUserJSON(
|
|
"<command-name>/effort</command-name>",
|
|
tsZeroS1,
|
|
),
|
|
)
|
|
sess, _ := runClaudeParserTest(t, "test.jsonl", content)
|
|
assert.Equal(t, "", sess.FirstMessage)
|
|
assert.Equal(t, 2, sess.UserMessageCount)
|
|
})
|
|
|
|
t.Run("hyphenated slash command is skipped, next message becomes first_message", func(t *testing.T) {
|
|
content := testjsonl.JoinJSONL(
|
|
testjsonl.ClaudeUserJSON(
|
|
"<command-message>roborev-fix</command-message>\n<command-name>/roborev-fix</command-name>\n<command-args>450</command-args>",
|
|
tsZero,
|
|
),
|
|
testjsonl.ClaudeUserJSON("follow-up", tsZeroS1),
|
|
)
|
|
sess, _ := runClaudeParserTest(t, "test.jsonl", content)
|
|
assert.Equal(t, "follow-up", sess.FirstMessage)
|
|
})
|
|
}
|