Compare commits
2 commits
dev
...
ax/review-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a10e30d9db | ||
|
|
294892c83b |
11 changed files with 514 additions and 204 deletions
62
analytics.go
62
analytics.go
|
|
@ -31,7 +31,7 @@ type SessionAnalytics struct {
|
|||
// Example:
|
||||
// analytics := session.Analyse(sess)
|
||||
func Analyse(sess *Session) *SessionAnalytics {
|
||||
a := &SessionAnalytics{
|
||||
analytics := &SessionAnalytics{
|
||||
ToolCounts: make(map[string]int),
|
||||
ErrorCounts: make(map[string]int),
|
||||
AvgLatency: make(map[string]time.Duration),
|
||||
|
|
@ -39,11 +39,11 @@ func Analyse(sess *Session) *SessionAnalytics {
|
|||
}
|
||||
|
||||
if sess == nil {
|
||||
return a
|
||||
return analytics
|
||||
}
|
||||
|
||||
a.Duration = sess.EndTime.Sub(sess.StartTime)
|
||||
a.EventCount = len(sess.Events)
|
||||
analytics.Duration = sess.EndTime.Sub(sess.StartTime)
|
||||
analytics.EventCount = len(sess.Events)
|
||||
|
||||
// Track totals for latency averaging
|
||||
type latencyAccum struct {
|
||||
|
|
@ -57,23 +57,23 @@ func Analyse(sess *Session) *SessionAnalytics {
|
|||
|
||||
for evt := range sess.EventsSeq() {
|
||||
// Token estimation: ~4 chars per token
|
||||
a.EstimatedInputTokens += len(evt.Input) / 4
|
||||
a.EstimatedOutputTokens += len(evt.Output) / 4
|
||||
analytics.EstimatedInputTokens += len(evt.Input) / 4
|
||||
analytics.EstimatedOutputTokens += len(evt.Output) / 4
|
||||
|
||||
if evt.Type != "tool_use" {
|
||||
continue
|
||||
}
|
||||
|
||||
totalToolCalls++
|
||||
a.ToolCounts[evt.Tool]++
|
||||
analytics.ToolCounts[evt.Tool]++
|
||||
|
||||
if !evt.Success {
|
||||
totalErrors++
|
||||
a.ErrorCounts[evt.Tool]++
|
||||
analytics.ErrorCounts[evt.Tool]++
|
||||
}
|
||||
|
||||
// Active time: sum of tool call durations
|
||||
a.ActiveTime += evt.Duration
|
||||
analytics.ActiveTime += evt.Duration
|
||||
|
||||
// Latency tracking
|
||||
if _, ok := latencies[evt.Tool]; !ok {
|
||||
|
|
@ -82,24 +82,24 @@ func Analyse(sess *Session) *SessionAnalytics {
|
|||
latencies[evt.Tool].total += evt.Duration
|
||||
latencies[evt.Tool].count++
|
||||
|
||||
if evt.Duration > a.MaxLatency[evt.Tool] {
|
||||
a.MaxLatency[evt.Tool] = evt.Duration
|
||||
if evt.Duration > analytics.MaxLatency[evt.Tool] {
|
||||
analytics.MaxLatency[evt.Tool] = evt.Duration
|
||||
}
|
||||
}
|
||||
|
||||
// Compute averages
|
||||
for tool, acc := range latencies {
|
||||
if acc.count > 0 {
|
||||
a.AvgLatency[tool] = acc.total / time.Duration(acc.count)
|
||||
for tool, accumulator := range latencies {
|
||||
if accumulator.count > 0 {
|
||||
analytics.AvgLatency[tool] = accumulator.total / time.Duration(accumulator.count)
|
||||
}
|
||||
}
|
||||
|
||||
// Success rate
|
||||
if totalToolCalls > 0 {
|
||||
a.SuccessRate = float64(totalToolCalls-totalErrors) / float64(totalToolCalls)
|
||||
analytics.SuccessRate = float64(totalToolCalls-totalErrors) / float64(totalToolCalls)
|
||||
}
|
||||
|
||||
return a
|
||||
return analytics
|
||||
}
|
||||
|
||||
// FormatAnalytics returns a tabular text summary suitable for CLI display.
|
||||
|
|
@ -107,35 +107,35 @@ func Analyse(sess *Session) *SessionAnalytics {
|
|||
// Example:
|
||||
// summary := session.FormatAnalytics(analytics)
|
||||
func FormatAnalytics(a *SessionAnalytics) string {
|
||||
b := core.NewBuilder()
|
||||
builder := core.NewBuilder()
|
||||
|
||||
b.WriteString("Session Analytics\n")
|
||||
b.WriteString(repeatString("=", 50) + "\n\n")
|
||||
builder.WriteString("Session Analytics\n")
|
||||
builder.WriteString(repeatString("=", 50) + "\n\n")
|
||||
|
||||
b.WriteString(core.Sprintf(" Duration: %s\n", formatDuration(a.Duration)))
|
||||
b.WriteString(core.Sprintf(" Active Time: %s\n", formatDuration(a.ActiveTime)))
|
||||
b.WriteString(core.Sprintf(" Events: %d\n", a.EventCount))
|
||||
b.WriteString(core.Sprintf(" Success Rate: %.1f%%\n", a.SuccessRate*100))
|
||||
b.WriteString(core.Sprintf(" Est. Input Tk: %d\n", a.EstimatedInputTokens))
|
||||
b.WriteString(core.Sprintf(" Est. Output Tk: %d\n", a.EstimatedOutputTokens))
|
||||
builder.WriteString(core.Sprintf(" Duration: %s\n", formatDuration(a.Duration)))
|
||||
builder.WriteString(core.Sprintf(" Active Time: %s\n", formatDuration(a.ActiveTime)))
|
||||
builder.WriteString(core.Sprintf(" Events: %d\n", a.EventCount))
|
||||
builder.WriteString(core.Sprintf(" Success Rate: %.1f%%\n", a.SuccessRate*100))
|
||||
builder.WriteString(core.Sprintf(" Est. Input Tk: %d\n", a.EstimatedInputTokens))
|
||||
builder.WriteString(core.Sprintf(" Est. Output Tk: %d\n", a.EstimatedOutputTokens))
|
||||
|
||||
if len(a.ToolCounts) > 0 {
|
||||
b.WriteString("\n Tool Breakdown\n")
|
||||
b.WriteString(" " + repeatString("-", 48) + "\n")
|
||||
b.WriteString(core.Sprintf(" %-14s %6s %6s %10s %10s\n",
|
||||
builder.WriteString("\n Tool Breakdown\n")
|
||||
builder.WriteString(" " + repeatString("-", 48) + "\n")
|
||||
builder.WriteString(core.Sprintf(" %-14s %6s %6s %10s %10s\n",
|
||||
"Tool", "Calls", "Errors", "Avg", "Max"))
|
||||
b.WriteString(" " + repeatString("-", 48) + "\n")
|
||||
builder.WriteString(" " + repeatString("-", 48) + "\n")
|
||||
|
||||
// Sort tools for deterministic output
|
||||
for _, tool := range slices.Sorted(maps.Keys(a.ToolCounts)) {
|
||||
errors := a.ErrorCounts[tool]
|
||||
avg := a.AvgLatency[tool]
|
||||
max := a.MaxLatency[tool]
|
||||
b.WriteString(core.Sprintf(" %-14s %6d %6d %10s %10s\n",
|
||||
builder.WriteString(core.Sprintf(" %-14s %6d %6d %10s %10s\n",
|
||||
tool, a.ToolCounts[tool], errors,
|
||||
formatDuration(avg), formatDuration(max)))
|
||||
}
|
||||
}
|
||||
|
||||
return b.String()
|
||||
return builder.String()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -283,3 +283,101 @@ func TestAnalytics_FormatAnalyticsEmptyAnalytics_Good(t *testing.T) {
|
|||
// No tool breakdown section when no tools
|
||||
assert.NotContains(t, output, "Tool Breakdown")
|
||||
}
|
||||
|
||||
func TestAnalytics_AnalyseAllToolsFailed_Bad(t *testing.T) {
|
||||
sess := &Session{
|
||||
ID: "all-failed",
|
||||
StartTime: time.Date(2026, 2, 20, 10, 0, 0, 0, time.UTC),
|
||||
EndTime: time.Date(2026, 2, 20, 10, 0, 5, 0, time.UTC),
|
||||
Events: []Event{
|
||||
{
|
||||
Type: "tool_use",
|
||||
Tool: "Bash",
|
||||
Input: "false",
|
||||
Output: "exit code 1",
|
||||
Duration: 100 * time.Millisecond,
|
||||
Success: false,
|
||||
ErrorMsg: "exit code 1",
|
||||
},
|
||||
{
|
||||
Type: "tool_use",
|
||||
Tool: "Read",
|
||||
Input: "/nonexistent",
|
||||
Output: "file not found",
|
||||
Duration: 50 * time.Millisecond,
|
||||
Success: false,
|
||||
ErrorMsg: "file not found",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
a := Analyse(sess)
|
||||
|
||||
assert.Equal(t, 0.0, a.SuccessRate, "all failures should give 0.0 success rate")
|
||||
assert.Equal(t, 1, a.ErrorCounts["Bash"])
|
||||
assert.Equal(t, 1, a.ErrorCounts["Read"])
|
||||
assert.Equal(t, 1, a.ToolCounts["Bash"])
|
||||
assert.Equal(t, 1, a.ToolCounts["Read"])
|
||||
}
|
||||
|
||||
func TestAnalytics_AnalyseSessionOnlyNonToolEvents_Bad(t *testing.T) {
|
||||
// Session with only user/assistant events — no tool_use — should produce zero tool stats
|
||||
sess := &Session{
|
||||
ID: "no-tools",
|
||||
StartTime: time.Date(2026, 2, 20, 10, 0, 0, 0, time.UTC),
|
||||
EndTime: time.Date(2026, 2, 20, 10, 0, 10, 0, time.UTC),
|
||||
Events: []Event{
|
||||
{Type: "user", Input: "Hello"},
|
||||
{Type: "assistant", Input: "Hi"},
|
||||
{Type: "user", Input: "Thanks"},
|
||||
},
|
||||
}
|
||||
|
||||
a := Analyse(sess)
|
||||
|
||||
assert.Equal(t, 3, a.EventCount)
|
||||
assert.Equal(t, 0.0, a.SuccessRate)
|
||||
assert.Empty(t, a.ToolCounts)
|
||||
assert.Empty(t, a.ErrorCounts)
|
||||
assert.Equal(t, time.Duration(0), a.ActiveTime)
|
||||
}
|
||||
|
||||
func TestAnalytics_FormatAnalyticsNilMaps_Ugly(t *testing.T) {
|
||||
// FormatAnalytics with nil maps — should not panic
|
||||
a := &SessionAnalytics{}
|
||||
|
||||
var output string
|
||||
assert.NotPanics(t, func() {
|
||||
output = FormatAnalytics(a)
|
||||
})
|
||||
assert.Contains(t, output, "Session Analytics")
|
||||
}
|
||||
|
||||
func TestAnalytics_AnalyseVeryLargeEventCount_Ugly(t *testing.T) {
|
||||
// Stress: session with 10 000 tool events — should not panic or overflow
|
||||
events := make([]Event, 10_000)
|
||||
for i := range events {
|
||||
events[i] = Event{
|
||||
Type: "tool_use",
|
||||
Tool: "Bash",
|
||||
Input: repeatString("x", 100),
|
||||
Output: repeatString("y", 200),
|
||||
Duration: time.Millisecond,
|
||||
Success: i%2 == 0,
|
||||
}
|
||||
}
|
||||
sess := &Session{
|
||||
ID: "huge",
|
||||
StartTime: time.Date(2026, 2, 20, 10, 0, 0, 0, time.UTC),
|
||||
EndTime: time.Date(2026, 2, 20, 11, 0, 0, 0, time.UTC),
|
||||
Events: events,
|
||||
}
|
||||
|
||||
var a *SessionAnalytics
|
||||
assert.NotPanics(t, func() {
|
||||
a = Analyse(sess)
|
||||
})
|
||||
assert.Equal(t, 10_000, a.EventCount)
|
||||
assert.Equal(t, 10_000, a.ToolCounts["Bash"])
|
||||
assert.InDelta(t, 0.5, a.SuccessRate, 0.001)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -92,12 +92,12 @@ func BenchmarkSearch(b *testing.B) {
|
|||
func generateBenchJSONL(b testing.TB, dir string, numTools int) string {
|
||||
b.Helper()
|
||||
|
||||
sb := core.NewBuilder()
|
||||
builder := core.NewBuilder()
|
||||
baseTS := "2026-02-20T10:00:00Z"
|
||||
|
||||
// Opening user message
|
||||
sb.WriteString(core.Sprintf(`{"type":"user","timestamp":"%s","sessionId":"bench","message":{"role":"user","content":[{"type":"text","text":"Start benchmark session"}]}}`, baseTS))
|
||||
sb.WriteByte('\n')
|
||||
builder.WriteString(core.Sprintf(`{"type":"user","timestamp":"%s","sessionId":"bench","message":{"role":"user","content":[{"type":"text","text":"Start benchmark session"}]}}`, baseTS))
|
||||
builder.WriteByte('\n')
|
||||
|
||||
for i := range numTools {
|
||||
toolID := core.Sprintf("tool-%d", i)
|
||||
|
|
@ -133,18 +133,18 @@ func generateBenchJSONL(b testing.TB, dir string, numTools int) string {
|
|||
(offset+1)/60, (offset+1)%60, toolID)
|
||||
}
|
||||
|
||||
sb.WriteString(toolUse)
|
||||
sb.WriteByte('\n')
|
||||
sb.WriteString(toolResult)
|
||||
sb.WriteByte('\n')
|
||||
builder.WriteString(toolUse)
|
||||
builder.WriteByte('\n')
|
||||
builder.WriteString(toolResult)
|
||||
builder.WriteByte('\n')
|
||||
}
|
||||
|
||||
// Closing assistant message
|
||||
sb.WriteString(core.Sprintf(`{"type":"assistant","timestamp":"2026-02-20T12:00:00Z","sessionId":"bench","message":{"role":"assistant","content":[{"type":"text","text":"Benchmark session complete."}]}}%s`, "\n"))
|
||||
builder.WriteString(core.Sprintf(`{"type":"assistant","timestamp":"2026-02-20T12:00:00Z","sessionId":"bench","message":{"role":"assistant","content":[{"type":"text","text":"Benchmark session complete."}]}}%s`, "\n"))
|
||||
|
||||
name := core.Sprintf("bench-%d.jsonl", numTools)
|
||||
filePath := path.Join(dir, name)
|
||||
writeResult := hostFS.Write(filePath, sb.String())
|
||||
writeResult := hostFS.Write(filePath, builder.String())
|
||||
if !writeResult.OK {
|
||||
b.Fatal(resultError(writeResult))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -195,6 +195,73 @@ func parseGoFiles(t *testing.T, dir string) []parsedFile {
|
|||
return files
|
||||
}
|
||||
|
||||
func TestConventions_BannedImportPresent_Bad(t *testing.T) {
|
||||
// Verify the banned-import check fires when a banned import is used.
|
||||
// We parse a synthetic file that imports "fmt" and confirm the list is non-empty.
|
||||
dir := t.TempDir()
|
||||
writeTestFile(t, path.Join(dir, "bad.go"), "package session\nimport \"fmt\"\nvar _ = fmt.Sprintf\n")
|
||||
|
||||
files := parseGoFiles(t, dir)
|
||||
if len(files) != 1 {
|
||||
t.Fatalf("expected 1 file, got %d", len(files))
|
||||
}
|
||||
|
||||
banned := map[string]bool{"fmt": true}
|
||||
var found []string
|
||||
for _, spec := range files[0].ast.Imports {
|
||||
importPath := trimQuotes(spec.Path.Value)
|
||||
if banned[importPath] {
|
||||
found = append(found, importPath)
|
||||
}
|
||||
}
|
||||
|
||||
if len(found) == 0 {
|
||||
t.Fatal("expected to detect banned import 'fmt' in synthetic file, got none")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConventions_TestNamingInvalidPattern_Bad(t *testing.T) {
|
||||
// A function that begins with Test but lacks the Good/Bad/Ugly suffix should not
|
||||
// match testNamePattern.
|
||||
badNames := []string{
|
||||
"TestFoo",
|
||||
"TestFoo_Bar",
|
||||
"TestFoo_Bar_",
|
||||
"TestFoo_Bar_Maybe",
|
||||
}
|
||||
for _, name := range badNames {
|
||||
if testNamePattern.MatchString(name) {
|
||||
t.Errorf("name %q should NOT match testNamePattern", name)
|
||||
}
|
||||
}
|
||||
|
||||
goodNames := []string{
|
||||
"TestFoo_Bar_Good",
|
||||
"TestFoo_Bar_Bad",
|
||||
"TestFoo_Bar_Ugly",
|
||||
}
|
||||
for _, name := range goodNames {
|
||||
if !testNamePattern.MatchString(name) {
|
||||
t.Errorf("name %q should match testNamePattern", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConventions_ParseGoFilesEmptyDir_Ugly(t *testing.T) {
|
||||
// parseGoFiles with no Go files in dir should fatalf — we test via a dir
|
||||
// that only has a non-Go file.
|
||||
dir := t.TempDir()
|
||||
writeTestFile(t, path.Join(dir, "readme.md"), "# hello\n")
|
||||
|
||||
// We cannot call t.Fatal from within the deferred function, so we capture the
|
||||
// outcome by running parseGoFiles through a stub that overrides Fatal.
|
||||
// Instead, verify that PathGlob returns empty for that dir.
|
||||
matches := core.PathGlob(path.Join(dir, "*.go"))
|
||||
if len(matches) != 0 {
|
||||
t.Fatalf("expected no .go files in temp dir, got %d", len(matches))
|
||||
}
|
||||
}
|
||||
|
||||
func TestConventions_ParseGoFilesMultiplePackages_Good(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
|
|
|
|||
|
|
@ -26,6 +26,9 @@ func (m rawJSON) MarshalJSON() ([]byte, error) {
|
|||
return m, nil
|
||||
}
|
||||
|
||||
// resultError extracts the error from a core.Result, or constructs one if absent.
|
||||
//
|
||||
// err := resultError(hostFS.Write(path, content))
|
||||
func resultError(result core.Result) error {
|
||||
if result.OK {
|
||||
return nil
|
||||
|
|
@ -36,32 +39,44 @@ func resultError(result core.Result) error {
|
|||
return core.E("resultError", "unexpected core result failure", nil)
|
||||
}
|
||||
|
||||
func repeatString(s string, count int) string {
|
||||
if s == "" || count <= 0 {
|
||||
// repeatString returns text repeated count times.
|
||||
//
|
||||
// repeatString("=", 50) // "=================================================="
|
||||
func repeatString(text string, count int) string {
|
||||
if text == "" || count <= 0 {
|
||||
return ""
|
||||
}
|
||||
return string(bytes.Repeat([]byte(s), count))
|
||||
return string(bytes.Repeat([]byte(text), count))
|
||||
}
|
||||
|
||||
func containsAny(s, chars string) bool {
|
||||
for _, ch := range chars {
|
||||
if bytes.IndexRune([]byte(s), ch) >= 0 {
|
||||
// containsAny reports whether text contains any rune in chars.
|
||||
//
|
||||
// containsAny("/tmp/file", `/\`) // true
|
||||
func containsAny(text, chars string) bool {
|
||||
for _, character := range chars {
|
||||
if bytes.IndexRune([]byte(text), character) >= 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func indexOf(s, substr string) int {
|
||||
return bytes.Index([]byte(s), []byte(substr))
|
||||
// indexOf returns the byte offset of substr in text, or -1 if not found.
|
||||
//
|
||||
// indexOf("hello world", "world") // 6
|
||||
func indexOf(text, substr string) int {
|
||||
return bytes.Index([]byte(text), []byte(substr))
|
||||
}
|
||||
|
||||
func trimQuotes(s string) string {
|
||||
if len(s) < 2 {
|
||||
return s
|
||||
// trimQuotes strips a single layer of matching double-quotes or back-ticks.
|
||||
//
|
||||
// trimQuotes(`"hello"`) // "hello"
|
||||
func trimQuotes(text string) string {
|
||||
if len(text) < 2 {
|
||||
return text
|
||||
}
|
||||
if (s[0] == '"' && s[len(s)-1] == '"') || (s[0] == '`' && s[len(s)-1] == '`') {
|
||||
return s[1 : len(s)-1]
|
||||
if (text[0] == '"' && text[len(text)-1] == '"') || (text[0] == '`' && text[len(text)-1] == '`') {
|
||||
return text[1 : len(text)-1]
|
||||
}
|
||||
return s
|
||||
return text
|
||||
}
|
||||
|
|
|
|||
52
html.go
52
html.go
|
|
@ -21,17 +21,17 @@ func RenderHTML(sess *Session, outputPath string) error {
|
|||
duration := sess.EndTime.Sub(sess.StartTime)
|
||||
toolCount := 0
|
||||
errorCount := 0
|
||||
for e := range sess.EventsSeq() {
|
||||
if e.Type == "tool_use" {
|
||||
for evt := range sess.EventsSeq() {
|
||||
if evt.Type == "tool_use" {
|
||||
toolCount++
|
||||
if !e.Success {
|
||||
if !evt.Success {
|
||||
errorCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
b := core.NewBuilder()
|
||||
b.WriteString(core.Sprintf(`<!DOCTYPE html>
|
||||
builder := core.NewBuilder()
|
||||
builder.WriteString(core.Sprintf(`<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
|
|
@ -96,11 +96,11 @@ body { background: var(--bg); color: var(--fg); font-family: var(--font); font-s
|
|||
toolCount))
|
||||
|
||||
if errorCount > 0 {
|
||||
b.WriteString(core.Sprintf(`
|
||||
builder.WriteString(core.Sprintf(`
|
||||
<span class="err">%d errors</span>`, errorCount))
|
||||
}
|
||||
|
||||
b.WriteString(`
|
||||
builder.WriteString(`
|
||||
</div>
|
||||
</div>
|
||||
<div class="search">
|
||||
|
|
@ -117,7 +117,7 @@ body { background: var(--bg); color: var(--fg); font-family: var(--font); font-s
|
|||
<div class="timeline" id="timeline">
|
||||
`)
|
||||
|
||||
var i int
|
||||
var eventIndex int
|
||||
for evt := range sess.EventsSeq() {
|
||||
toolClass := core.Lower(evt.Tool)
|
||||
if evt.Type == "user" {
|
||||
|
|
@ -147,12 +147,12 @@ body { background: var(--bg); color: var(--fg); font-family: var(--font); font-s
|
|||
toolLabel = "Claude"
|
||||
}
|
||||
|
||||
durStr := ""
|
||||
durationString := ""
|
||||
if evt.Duration > 0 {
|
||||
durStr = formatDuration(evt.Duration)
|
||||
durationString = formatDuration(evt.Duration)
|
||||
}
|
||||
|
||||
b.WriteString(core.Sprintf(`<div class="event%s" data-type="%s" data-tool="%s" data-text="%s" id="evt-%d">
|
||||
builder.WriteString(core.Sprintf(`<div class="event%s" data-type="%s" data-tool="%s" data-text="%s" id="evt-%d">
|
||||
<div class="event-header" onclick="toggle(%d)">
|
||||
<span class="arrow">▶</span>
|
||||
<span class="time">%s</span>
|
||||
|
|
@ -167,13 +167,13 @@ body { background: var(--bg); color: var(--fg); font-family: var(--font); font-s
|
|||
evt.Type,
|
||||
evt.Tool,
|
||||
html.EscapeString(core.Lower(core.Concat(evt.Input, " ", evt.Output))),
|
||||
i,
|
||||
i,
|
||||
eventIndex,
|
||||
eventIndex,
|
||||
evt.Timestamp.Format("15:04:05"),
|
||||
toolClass,
|
||||
html.EscapeString(toolLabel),
|
||||
html.EscapeString(truncate(evt.Input, 120)),
|
||||
durStr,
|
||||
durationString,
|
||||
statusIcon))
|
||||
|
||||
if evt.Input != "" {
|
||||
|
|
@ -187,26 +187,26 @@ body { background: var(--bg); color: var(--fg); font-family: var(--font); font-s
|
|||
} else if evt.Tool == "Edit" || evt.Tool == "Write" {
|
||||
label = "File"
|
||||
}
|
||||
b.WriteString(core.Sprintf(` <div class="section"><div class="label">%s</div><pre>%s</pre></div>
|
||||
builder.WriteString(core.Sprintf(` <div class="section"><div class="label">%s</div><pre>%s</pre></div>
|
||||
`, label, html.EscapeString(evt.Input)))
|
||||
}
|
||||
|
||||
if evt.Output != "" {
|
||||
outClass := "output"
|
||||
outputClass := "output"
|
||||
if !evt.Success {
|
||||
outClass = "output err"
|
||||
outputClass = "output err"
|
||||
}
|
||||
b.WriteString(core.Sprintf(` <div class="section"><div class="label">Output</div><pre class="%s">%s</pre></div>
|
||||
`, outClass, html.EscapeString(evt.Output)))
|
||||
builder.WriteString(core.Sprintf(` <div class="section"><div class="label">Output</div><pre class="%s">%s</pre></div>
|
||||
`, outputClass, html.EscapeString(evt.Output)))
|
||||
}
|
||||
|
||||
b.WriteString(` </div>
|
||||
builder.WriteString(` </div>
|
||||
</div>
|
||||
`)
|
||||
i++
|
||||
eventIndex++
|
||||
}
|
||||
|
||||
b.WriteString(`</div>
|
||||
builder.WriteString(`</div>
|
||||
<script>
|
||||
function toggle(i) {
|
||||
document.getElementById('evt-'+i).classList.toggle('open');
|
||||
|
|
@ -238,7 +238,7 @@ document.addEventListener('keydown', e => {
|
|||
</html>
|
||||
`)
|
||||
|
||||
writeResult := hostFS.Write(outputPath, b.String())
|
||||
writeResult := hostFS.Write(outputPath, builder.String())
|
||||
if !writeResult.OK {
|
||||
return core.E("RenderHTML", "write html", resultError(writeResult))
|
||||
}
|
||||
|
|
@ -246,6 +246,9 @@ document.addEventListener('keydown', e => {
|
|||
return nil
|
||||
}
|
||||
|
||||
// shortID returns the first 8 characters of an ID for display purposes.
|
||||
//
|
||||
// shortID("abc123def456") // "abc123de"
|
||||
func shortID(id string) string {
|
||||
if len(id) > 8 {
|
||||
return id[:8]
|
||||
|
|
@ -253,6 +256,9 @@ func shortID(id string) string {
|
|||
return id
|
||||
}
|
||||
|
||||
// formatDuration renders a duration as a compact human-readable string.
|
||||
//
|
||||
// formatDuration(5*time.Minute + 30*time.Second) // "5m30s"
|
||||
func formatDuration(d time.Duration) string {
|
||||
if d < time.Second {
|
||||
return core.Sprintf("%dms", d.Milliseconds())
|
||||
|
|
|
|||
65
html_test.go
65
html_test.go
|
|
@ -230,3 +230,68 @@ func TestHTML_RenderHTMLLabelsByToolType_Good(t *testing.T) {
|
|||
// Edit, Write get "File" label
|
||||
assert.True(t, core.Contains(html, "File"), "Edit/Write events should use 'File' label")
|
||||
}
|
||||
|
||||
func TestHTML_RenderHTMLSessionWithNoEvents_Bad(t *testing.T) {
|
||||
// A session that has zero events should still produce valid HTML with 0 tool calls
|
||||
dir := t.TempDir()
|
||||
outputPath := dir + "/no-events.html"
|
||||
|
||||
sess := &Session{
|
||||
ID: "no-events",
|
||||
StartTime: time.Date(2026, 2, 20, 10, 0, 0, 0, time.UTC),
|
||||
EndTime: time.Date(2026, 2, 20, 10, 0, 0, 0, time.UTC),
|
||||
Events: []Event{},
|
||||
}
|
||||
|
||||
err := RenderHTML(sess, outputPath)
|
||||
require.NoError(t, err)
|
||||
|
||||
readResult := hostFS.Read(outputPath)
|
||||
require.True(t, readResult.OK)
|
||||
htmlContent := readResult.Value.(string)
|
||||
assert.Contains(t, htmlContent, "0 tool calls")
|
||||
assert.Contains(t, htmlContent, "<!DOCTYPE html>")
|
||||
}
|
||||
|
||||
func TestHTML_RenderHTMLAllEventsAreErrors_Bad(t *testing.T) {
|
||||
// All tool events failed — error count should match tool count
|
||||
dir := t.TempDir()
|
||||
outputPath := dir + "/all-errors.html"
|
||||
|
||||
sess := &Session{
|
||||
ID: "all-errors",
|
||||
StartTime: time.Date(2026, 2, 20, 10, 0, 0, 0, time.UTC),
|
||||
EndTime: time.Date(2026, 2, 20, 10, 0, 5, 0, time.UTC),
|
||||
Events: []Event{
|
||||
{
|
||||
Timestamp: time.Date(2026, 2, 20, 10, 0, 0, 0, time.UTC),
|
||||
Type: "tool_use",
|
||||
Tool: "Bash",
|
||||
Input: "false",
|
||||
Output: "exit 1",
|
||||
Duration: 50 * time.Millisecond,
|
||||
Success: false,
|
||||
ErrorMsg: "exit 1",
|
||||
},
|
||||
{
|
||||
Timestamp: time.Date(2026, 2, 20, 10, 0, 2, 0, time.UTC),
|
||||
Type: "tool_use",
|
||||
Tool: "Bash",
|
||||
Input: "cat /missing",
|
||||
Output: "No such file",
|
||||
Duration: 30 * time.Millisecond,
|
||||
Success: false,
|
||||
ErrorMsg: "No such file",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
err := RenderHTML(sess, outputPath)
|
||||
require.NoError(t, err)
|
||||
|
||||
readResult := hostFS.Read(outputPath)
|
||||
require.True(t, readResult.OK)
|
||||
htmlContent := readResult.Value.(string)
|
||||
assert.Contains(t, htmlContent, "2 errors")
|
||||
assert.Contains(t, htmlContent, `class="event error"`)
|
||||
}
|
||||
|
|
|
|||
173
parser.go
173
parser.go
|
|
@ -165,7 +165,7 @@ func ListSessionsSeq(projectsDir string) iter.Seq[Session] {
|
|||
continue
|
||||
}
|
||||
|
||||
s := Session{
|
||||
sess := Session{
|
||||
ID: id,
|
||||
Path: filePath,
|
||||
}
|
||||
|
|
@ -175,14 +175,14 @@ func ListSessionsSeq(projectsDir string) iter.Seq[Session] {
|
|||
if !openResult.OK {
|
||||
continue
|
||||
}
|
||||
f, ok := openResult.Value.(io.ReadCloser)
|
||||
fileHandle, ok := openResult.Value.(io.ReadCloser)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
scanner := bufio.NewScanner(f)
|
||||
scanner := bufio.NewScanner(fileHandle)
|
||||
scanner.Buffer(make([]byte, 1024*1024), 1024*1024)
|
||||
var firstTS, lastTS string
|
||||
var firstTimestamp, lastTimestamp string
|
||||
for scanner.Scan() {
|
||||
var entry rawEntry
|
||||
if !core.JSONUnmarshal(scanner.Bytes(), &entry).OK {
|
||||
|
|
@ -191,36 +191,36 @@ func ListSessionsSeq(projectsDir string) iter.Seq[Session] {
|
|||
if entry.Timestamp == "" {
|
||||
continue
|
||||
}
|
||||
if firstTS == "" {
|
||||
firstTS = entry.Timestamp
|
||||
if firstTimestamp == "" {
|
||||
firstTimestamp = entry.Timestamp
|
||||
}
|
||||
lastTS = entry.Timestamp
|
||||
lastTimestamp = entry.Timestamp
|
||||
}
|
||||
f.Close()
|
||||
fileHandle.Close()
|
||||
|
||||
if firstTS != "" {
|
||||
if t, err := time.Parse(time.RFC3339Nano, firstTS); err == nil {
|
||||
s.StartTime = t
|
||||
if firstTimestamp != "" {
|
||||
if parsedTime, err := time.Parse(time.RFC3339Nano, firstTimestamp); err == nil {
|
||||
sess.StartTime = parsedTime
|
||||
}
|
||||
}
|
||||
if lastTS != "" {
|
||||
if t, err := time.Parse(time.RFC3339Nano, lastTS); err == nil {
|
||||
s.EndTime = t
|
||||
if lastTimestamp != "" {
|
||||
if parsedTime, err := time.Parse(time.RFC3339Nano, lastTimestamp); err == nil {
|
||||
sess.EndTime = parsedTime
|
||||
}
|
||||
}
|
||||
if s.StartTime.IsZero() {
|
||||
s.StartTime = info.ModTime()
|
||||
if sess.StartTime.IsZero() {
|
||||
sess.StartTime = info.ModTime()
|
||||
}
|
||||
|
||||
sessions = append(sessions, s)
|
||||
sessions = append(sessions, sess)
|
||||
}
|
||||
|
||||
slices.SortFunc(sessions, func(i, j Session) int {
|
||||
return j.StartTime.Compare(i.StartTime)
|
||||
})
|
||||
|
||||
for _, s := range sessions {
|
||||
if !yield(s) {
|
||||
for _, sess := range sessions {
|
||||
if !yield(sess) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
|
@ -292,16 +292,16 @@ func ParseTranscript(filePath string) (*Session, *ParseStats, error) {
|
|||
if !openResult.OK {
|
||||
return nil, nil, core.E("ParseTranscript", "open transcript", resultError(openResult))
|
||||
}
|
||||
f, ok := openResult.Value.(io.ReadCloser)
|
||||
fileHandle, ok := openResult.Value.(io.ReadCloser)
|
||||
if !ok {
|
||||
return nil, nil, core.E("ParseTranscript", "unexpected file handle type", nil)
|
||||
}
|
||||
defer f.Close()
|
||||
defer fileHandle.Close()
|
||||
|
||||
base := path.Base(filePath)
|
||||
id := core.TrimSuffix(base, ".jsonl")
|
||||
|
||||
sess, stats, err := parseFromReader(f, id)
|
||||
sess, stats, err := parseFromReader(fileHandle, id)
|
||||
if sess != nil {
|
||||
sess.Path = filePath
|
||||
}
|
||||
|
|
@ -325,9 +325,9 @@ func ParseTranscriptReader(r io.Reader, id string) (*Session, *ParseStats, error
|
|||
return sess, stats, nil
|
||||
}
|
||||
|
||||
// parseFromReader is the shared implementation for both file-based and
|
||||
// reader-based parsing. It scans line-by-line using bufio.Scanner with
|
||||
// an 8 MiB buffer, gracefully skipping malformed lines.
|
||||
// parseFromReader scans r line-by-line and returns parsed session events.
|
||||
//
|
||||
// sess, stats, err := parseFromReader(f, "abc123")
|
||||
func parseFromReader(r io.Reader, id string) (*Session, *ParseStats, error) {
|
||||
sess := &Session{
|
||||
ID: id,
|
||||
|
|
@ -375,27 +375,27 @@ func parseFromReader(r io.Reader, id string) (*Session, *ParseStats, error) {
|
|||
continue
|
||||
}
|
||||
|
||||
ts, err := time.Parse(time.RFC3339Nano, entry.Timestamp)
|
||||
entryTime, err := time.Parse(time.RFC3339Nano, entry.Timestamp)
|
||||
if err != nil {
|
||||
stats.Warnings = append(stats.Warnings, core.Sprintf("line %d: bad timestamp %q: %v", lineNum, entry.Timestamp, err))
|
||||
continue
|
||||
}
|
||||
|
||||
if sess.StartTime.IsZero() && !ts.IsZero() {
|
||||
sess.StartTime = ts
|
||||
if sess.StartTime.IsZero() && !entryTime.IsZero() {
|
||||
sess.StartTime = entryTime
|
||||
}
|
||||
if !ts.IsZero() {
|
||||
sess.EndTime = ts
|
||||
if !entryTime.IsZero() {
|
||||
sess.EndTime = entryTime
|
||||
}
|
||||
|
||||
switch entry.Type {
|
||||
case "assistant":
|
||||
var msg rawMessage
|
||||
if !core.JSONUnmarshal(entry.Message, &msg).OK {
|
||||
var message rawMessage
|
||||
if !core.JSONUnmarshal(entry.Message, &message).OK {
|
||||
stats.Warnings = append(stats.Warnings, core.Sprintf("line %d: failed to unmarshal assistant message", lineNum))
|
||||
continue
|
||||
}
|
||||
for i, raw := range msg.Content {
|
||||
for i, raw := range message.Content {
|
||||
var block contentBlock
|
||||
if !core.JSONUnmarshal(raw, &block).OK {
|
||||
stats.Warnings = append(stats.Warnings, core.Sprintf("line %d block %d: failed to unmarshal content", lineNum, i))
|
||||
|
|
@ -406,29 +406,29 @@ func parseFromReader(r io.Reader, id string) (*Session, *ParseStats, error) {
|
|||
case "text":
|
||||
if text := core.Trim(block.Text); text != "" {
|
||||
sess.Events = append(sess.Events, Event{
|
||||
Timestamp: ts,
|
||||
Timestamp: entryTime,
|
||||
Type: "assistant",
|
||||
Input: truncate(text, 500),
|
||||
})
|
||||
}
|
||||
|
||||
case "tool_use":
|
||||
inputStr := extractToolInput(block.Name, block.Input)
|
||||
toolInput := extractToolInput(block.Name, block.Input)
|
||||
pendingTools[block.ID] = toolUse{
|
||||
timestamp: ts,
|
||||
timestamp: entryTime,
|
||||
tool: block.Name,
|
||||
input: inputStr,
|
||||
input: toolInput,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
case "user":
|
||||
var msg rawMessage
|
||||
if !core.JSONUnmarshal(entry.Message, &msg).OK {
|
||||
var message rawMessage
|
||||
if !core.JSONUnmarshal(entry.Message, &message).OK {
|
||||
stats.Warnings = append(stats.Warnings, core.Sprintf("line %d: failed to unmarshal user message", lineNum))
|
||||
continue
|
||||
}
|
||||
for i, raw := range msg.Content {
|
||||
for i, raw := range message.Content {
|
||||
var block contentBlock
|
||||
if !core.JSONUnmarshal(raw, &block).OK {
|
||||
stats.Warnings = append(stats.Warnings, core.Sprintf("line %d block %d: failed to unmarshal content", lineNum, i))
|
||||
|
|
@ -437,17 +437,17 @@ func parseFromReader(r io.Reader, id string) (*Session, *ParseStats, error) {
|
|||
|
||||
switch block.Type {
|
||||
case "tool_result":
|
||||
if tu, ok := pendingTools[block.ToolUseID]; ok {
|
||||
if pendingTool, ok := pendingTools[block.ToolUseID]; ok {
|
||||
output := extractResultContent(block.Content)
|
||||
isError := block.IsError != nil && *block.IsError
|
||||
evt := Event{
|
||||
Timestamp: tu.timestamp,
|
||||
Timestamp: pendingTool.timestamp,
|
||||
Type: "tool_use",
|
||||
Tool: tu.tool,
|
||||
Tool: pendingTool.tool,
|
||||
ToolID: block.ToolUseID,
|
||||
Input: tu.input,
|
||||
Input: pendingTool.input,
|
||||
Output: truncate(output, 2000),
|
||||
Duration: ts.Sub(tu.timestamp),
|
||||
Duration: entryTime.Sub(pendingTool.timestamp),
|
||||
Success: !isError,
|
||||
}
|
||||
if isError {
|
||||
|
|
@ -460,7 +460,7 @@ func parseFromReader(r io.Reader, id string) (*Session, *ParseStats, error) {
|
|||
case "text":
|
||||
if text := core.Trim(block.Text); text != "" {
|
||||
sess.Events = append(sess.Events, Event{
|
||||
Timestamp: ts,
|
||||
Timestamp: entryTime,
|
||||
Type: "user",
|
||||
Input: truncate(text, 500),
|
||||
})
|
||||
|
|
@ -492,6 +492,9 @@ func parseFromReader(r io.Reader, id string) (*Session, *ParseStats, error) {
|
|||
return sess, stats, nil
|
||||
}
|
||||
|
||||
// extractToolInput decodes a tool's raw JSON input to a human-readable string.
|
||||
//
|
||||
// label := extractToolInput("Bash", raw) // "ls -la # list files"
|
||||
func extractToolInput(toolName string, raw rawJSON) string {
|
||||
if raw == nil {
|
||||
return ""
|
||||
|
|
@ -499,64 +502,67 @@ func extractToolInput(toolName string, raw rawJSON) string {
|
|||
|
||||
switch toolName {
|
||||
case "Bash":
|
||||
var inp bashInput
|
||||
if core.JSONUnmarshal(raw, &inp).OK {
|
||||
desc := inp.Description
|
||||
var input bashInput
|
||||
if core.JSONUnmarshal(raw, &input).OK {
|
||||
desc := input.Description
|
||||
if desc != "" {
|
||||
desc = " # " + desc
|
||||
}
|
||||
return inp.Command + desc
|
||||
return input.Command + desc
|
||||
}
|
||||
case "Read":
|
||||
var inp readInput
|
||||
if core.JSONUnmarshal(raw, &inp).OK {
|
||||
return inp.FilePath
|
||||
var input readInput
|
||||
if core.JSONUnmarshal(raw, &input).OK {
|
||||
return input.FilePath
|
||||
}
|
||||
case "Edit":
|
||||
var inp editInput
|
||||
if core.JSONUnmarshal(raw, &inp).OK {
|
||||
return core.Sprintf("%s (edit)", inp.FilePath)
|
||||
var input editInput
|
||||
if core.JSONUnmarshal(raw, &input).OK {
|
||||
return core.Sprintf("%s (edit)", input.FilePath)
|
||||
}
|
||||
case "Write":
|
||||
var inp writeInput
|
||||
if core.JSONUnmarshal(raw, &inp).OK {
|
||||
return core.Sprintf("%s (%d bytes)", inp.FilePath, len(inp.Content))
|
||||
var input writeInput
|
||||
if core.JSONUnmarshal(raw, &input).OK {
|
||||
return core.Sprintf("%s (%d bytes)", input.FilePath, len(input.Content))
|
||||
}
|
||||
case "Grep":
|
||||
var inp grepInput
|
||||
if core.JSONUnmarshal(raw, &inp).OK {
|
||||
path := inp.Path
|
||||
if path == "" {
|
||||
path = "."
|
||||
var input grepInput
|
||||
if core.JSONUnmarshal(raw, &input).OK {
|
||||
grepPath := input.Path
|
||||
if grepPath == "" {
|
||||
grepPath = "."
|
||||
}
|
||||
return core.Sprintf("/%s/ in %s", inp.Pattern, path)
|
||||
return core.Sprintf("/%s/ in %s", input.Pattern, grepPath)
|
||||
}
|
||||
case "Glob":
|
||||
var inp globInput
|
||||
if core.JSONUnmarshal(raw, &inp).OK {
|
||||
return inp.Pattern
|
||||
var input globInput
|
||||
if core.JSONUnmarshal(raw, &input).OK {
|
||||
return input.Pattern
|
||||
}
|
||||
case "Task":
|
||||
var inp taskInput
|
||||
if core.JSONUnmarshal(raw, &inp).OK {
|
||||
desc := inp.Description
|
||||
var input taskInput
|
||||
if core.JSONUnmarshal(raw, &input).OK {
|
||||
desc := input.Description
|
||||
if desc == "" {
|
||||
desc = truncate(inp.Prompt, 80)
|
||||
desc = truncate(input.Prompt, 80)
|
||||
}
|
||||
return core.Sprintf("[%s] %s", inp.SubagentType, desc)
|
||||
return core.Sprintf("[%s] %s", input.SubagentType, desc)
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: show raw JSON keys
|
||||
var m map[string]any
|
||||
if core.JSONUnmarshal(raw, &m).OK {
|
||||
parts := slices.Sorted(maps.Keys(m))
|
||||
var jsonFields map[string]any
|
||||
if core.JSONUnmarshal(raw, &jsonFields).OK {
|
||||
parts := slices.Sorted(maps.Keys(jsonFields))
|
||||
return core.Join(", ", parts...)
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// extractResultContent coerces a tool_result content value to a plain string.
|
||||
//
|
||||
// text := extractResultContent(block.Content) // "total 42\n..."
|
||||
func extractResultContent(content any) string {
|
||||
switch v := content.(type) {
|
||||
case string:
|
||||
|
|
@ -564,8 +570,8 @@ func extractResultContent(content any) string {
|
|||
case []any:
|
||||
var parts []string
|
||||
for _, item := range v {
|
||||
if m, ok := item.(map[string]any); ok {
|
||||
if text, ok := m["text"].(string); ok {
|
||||
if contentMap, ok := item.(map[string]any); ok {
|
||||
if text, ok := contentMap["text"].(string); ok {
|
||||
parts = append(parts, text)
|
||||
}
|
||||
}
|
||||
|
|
@ -579,9 +585,12 @@ func extractResultContent(content any) string {
|
|||
return core.Sprint(content)
|
||||
}
|
||||
|
||||
func truncate(s string, max int) string {
|
||||
if len(s) <= max {
|
||||
return s
|
||||
// truncate clips text to at most maxLen bytes, appending "..." if clipped.
|
||||
//
|
||||
// truncate("hello world", 5) // "hello..."
|
||||
func truncate(text string, maxLen int) string {
|
||||
if len(text) <= maxLen {
|
||||
return text
|
||||
}
|
||||
return s[:max] + "..."
|
||||
return text[:maxLen] + "..."
|
||||
}
|
||||
|
|
|
|||
12
search.go
12
search.go
|
|
@ -54,17 +54,17 @@ func SearchSeq(projectsDir, query string) iter.Seq[SearchResult] {
|
|||
}
|
||||
text := core.Lower(core.Concat(evt.Input, " ", evt.Output))
|
||||
if core.Contains(text, query) {
|
||||
matchCtx := evt.Input
|
||||
if matchCtx == "" {
|
||||
matchCtx = truncate(evt.Output, 120)
|
||||
matchContext := evt.Input
|
||||
if matchContext == "" {
|
||||
matchContext = truncate(evt.Output, 120)
|
||||
}
|
||||
res := SearchResult{
|
||||
result := SearchResult{
|
||||
SessionID: sess.ID,
|
||||
Timestamp: evt.Timestamp,
|
||||
Tool: evt.Tool,
|
||||
Match: matchCtx,
|
||||
Match: matchContext,
|
||||
}
|
||||
if !yield(res) {
|
||||
if !yield(result) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import (
|
|||
"path"
|
||||
"testing"
|
||||
|
||||
core "dappco.re/go/core"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
|
@ -163,3 +164,38 @@ func TestSearch_SearchMalformedSessionSkipped_Bad(t *testing.T) {
|
|||
require.NoError(t, err)
|
||||
assert.Len(t, results, 1, "should still find matches in valid sessions")
|
||||
}
|
||||
|
||||
func TestSearch_SearchEmptyQuery_Ugly(t *testing.T) {
|
||||
// An empty query string matches every tool event (empty substring is always contained)
|
||||
dir := t.TempDir()
|
||||
writeJSONL(t, dir, "session.jsonl",
|
||||
toolUseEntry(ts(0), "Bash", "t1", map[string]any{"command": "ls"}),
|
||||
toolResultEntry(ts(1), "t1", "file.go", false),
|
||||
toolUseEntry(ts(2), "Read", "t2", map[string]any{"file_path": "/tmp/x.go"}),
|
||||
toolResultEntry(ts(3), "t2", "package main", false),
|
||||
)
|
||||
|
||||
results, err := Search(dir, "")
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, results, 2, "empty query should match all tool events")
|
||||
}
|
||||
|
||||
func TestSearch_SearchSeqEarlyTermination_Ugly(t *testing.T) {
|
||||
// Caller breaks out of SearchSeq early — should not hang or panic
|
||||
dir := t.TempDir()
|
||||
for i := range 5 {
|
||||
name := core.Sprintf("session-%c.jsonl", rune('a'+i))
|
||||
writeJSONL(t, dir, name,
|
||||
toolUseEntry(ts(0), "Bash", "t1", map[string]any{"command": "go test"}),
|
||||
toolResultEntry(ts(1), "t1", "PASS", false),
|
||||
)
|
||||
}
|
||||
|
||||
var count int
|
||||
for range SearchSeq(dir, "go test") {
|
||||
count++
|
||||
break // early termination
|
||||
}
|
||||
|
||||
assert.Equal(t, 1, count, "early break from SearchSeq should yield exactly one result")
|
||||
}
|
||||
|
|
|
|||
92
video.go
92
video.go
|
|
@ -40,28 +40,31 @@ func RenderMP4(sess *Session, outputPath string) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// generateTape produces a VHS .tape script from session events.
|
||||
//
|
||||
// tape := generateTape(sess, "/tmp/session.mp4")
|
||||
func generateTape(sess *Session, outputPath string) string {
|
||||
b := core.NewBuilder()
|
||||
builder := core.NewBuilder()
|
||||
|
||||
b.WriteString(core.Sprintf("Output %s\n", outputPath))
|
||||
b.WriteString("Set FontSize 16\n")
|
||||
b.WriteString("Set Width 1400\n")
|
||||
b.WriteString("Set Height 800\n")
|
||||
b.WriteString("Set TypingSpeed 30ms\n")
|
||||
b.WriteString("Set Theme \"Catppuccin Mocha\"\n")
|
||||
b.WriteString("Set Shell bash\n")
|
||||
b.WriteString("\n")
|
||||
builder.WriteString(core.Sprintf("Output %s\n", outputPath))
|
||||
builder.WriteString("Set FontSize 16\n")
|
||||
builder.WriteString("Set Width 1400\n")
|
||||
builder.WriteString("Set Height 800\n")
|
||||
builder.WriteString("Set TypingSpeed 30ms\n")
|
||||
builder.WriteString("Set Theme \"Catppuccin Mocha\"\n")
|
||||
builder.WriteString("Set Shell bash\n")
|
||||
builder.WriteString("\n")
|
||||
|
||||
// Title frame
|
||||
id := sess.ID
|
||||
if len(id) > 8 {
|
||||
id = id[:8]
|
||||
sessionID := sess.ID
|
||||
if len(sessionID) > 8 {
|
||||
sessionID = sessionID[:8]
|
||||
}
|
||||
b.WriteString(core.Sprintf("Type \"# Session %s | %s\"\n",
|
||||
id, sess.StartTime.Format("2006-01-02 15:04")))
|
||||
b.WriteString("Enter\n")
|
||||
b.WriteString("Sleep 2s\n")
|
||||
b.WriteString("\n")
|
||||
builder.WriteString(core.Sprintf("Type \"# Session %s | %s\"\n",
|
||||
sessionID, sess.StartTime.Format("2006-01-02 15:04")))
|
||||
builder.WriteString("Enter\n")
|
||||
builder.WriteString("Sleep 2s\n")
|
||||
builder.WriteString("\n")
|
||||
|
||||
for _, evt := range sess.Events {
|
||||
if evt.Type != "tool_use" {
|
||||
|
|
@ -75,8 +78,8 @@ func generateTape(sess *Session, outputPath string) string {
|
|||
continue
|
||||
}
|
||||
// Show the command
|
||||
b.WriteString(core.Sprintf("Type %q\n", "$ "+cmd))
|
||||
b.WriteString("Enter\n")
|
||||
builder.WriteString(core.Sprintf("Type %q\n", "$ "+cmd))
|
||||
builder.WriteString("Enter\n")
|
||||
|
||||
// Show abbreviated output
|
||||
output := evt.Output
|
||||
|
|
@ -88,47 +91,52 @@ func generateTape(sess *Session, outputPath string) string {
|
|||
if line == "" {
|
||||
continue
|
||||
}
|
||||
b.WriteString(core.Sprintf("Type %q\n", line))
|
||||
b.WriteString("Enter\n")
|
||||
builder.WriteString(core.Sprintf("Type %q\n", line))
|
||||
builder.WriteString("Enter\n")
|
||||
}
|
||||
}
|
||||
|
||||
// Status indicator
|
||||
if !evt.Success {
|
||||
b.WriteString("Type \"# ✗ FAILED\"\n")
|
||||
builder.WriteString("Type \"# ✗ FAILED\"\n")
|
||||
} else {
|
||||
b.WriteString("Type \"# ✓ OK\"\n")
|
||||
builder.WriteString("Type \"# ✓ OK\"\n")
|
||||
}
|
||||
b.WriteString("Enter\n")
|
||||
b.WriteString("Sleep 1s\n")
|
||||
b.WriteString("\n")
|
||||
builder.WriteString("Enter\n")
|
||||
builder.WriteString("Sleep 1s\n")
|
||||
builder.WriteString("\n")
|
||||
|
||||
case "Read", "Edit", "Write":
|
||||
b.WriteString(core.Sprintf("Type %q\n",
|
||||
builder.WriteString(core.Sprintf("Type %q\n",
|
||||
core.Sprintf("# %s: %s", evt.Tool, truncate(evt.Input, 80))))
|
||||
b.WriteString("Enter\n")
|
||||
b.WriteString("Sleep 500ms\n")
|
||||
builder.WriteString("Enter\n")
|
||||
builder.WriteString("Sleep 500ms\n")
|
||||
|
||||
case "Task":
|
||||
b.WriteString(core.Sprintf("Type %q\n",
|
||||
builder.WriteString(core.Sprintf("Type %q\n",
|
||||
core.Sprintf("# Agent: %s", truncate(evt.Input, 80))))
|
||||
b.WriteString("Enter\n")
|
||||
b.WriteString("Sleep 1s\n")
|
||||
builder.WriteString("Enter\n")
|
||||
builder.WriteString("Sleep 1s\n")
|
||||
}
|
||||
}
|
||||
|
||||
b.WriteString("Sleep 3s\n")
|
||||
return b.String()
|
||||
builder.WriteString("Sleep 3s\n")
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
// extractCommand strips the description suffix from a Bash input string.
|
||||
//
|
||||
// extractCommand("ls -la # list files") // "ls -la"
|
||||
func extractCommand(input string) string {
|
||||
// Remove description suffix (after " # ")
|
||||
if idx := indexOf(input, " # "); idx > 0 {
|
||||
return input[:idx]
|
||||
if index := indexOf(input, " # "); index > 0 {
|
||||
return input[:index]
|
||||
}
|
||||
return input
|
||||
}
|
||||
|
||||
// lookupExecutable searches PATH for an executable with the given name.
|
||||
//
|
||||
// lookupExecutable("vhs") // "/usr/local/bin/vhs"
|
||||
func lookupExecutable(name string) string {
|
||||
if name == "" {
|
||||
return ""
|
||||
|
|
@ -152,6 +160,9 @@ func lookupExecutable(name string) string {
|
|||
return ""
|
||||
}
|
||||
|
||||
// isExecutablePath reports whether filePath is a regular executable file.
|
||||
//
|
||||
// isExecutablePath("/usr/bin/vhs") // true
|
||||
func isExecutablePath(filePath string) bool {
|
||||
statResult := hostFS.Stat(filePath)
|
||||
if !statResult.OK {
|
||||
|
|
@ -164,14 +175,17 @@ func isExecutablePath(filePath string) bool {
|
|||
return info.Mode()&0111 != 0
|
||||
}
|
||||
|
||||
// runCommand executes command with args, inheriting stdio, and waits for exit.
|
||||
//
|
||||
// err := runCommand("/usr/local/bin/vhs", "/tmp/session.tape")
|
||||
func runCommand(command string, args ...string) error {
|
||||
argv := append([]string{command}, args...)
|
||||
arguments := append([]string{command}, args...)
|
||||
procAttr := &syscall.ProcAttr{
|
||||
Env: syscall.Environ(),
|
||||
Files: []uintptr{0, 1, 2},
|
||||
}
|
||||
|
||||
pid, err := syscall.ForkExec(command, argv, procAttr)
|
||||
pid, err := syscall.ForkExec(command, arguments, procAttr)
|
||||
if err != nil {
|
||||
return core.E("runCommand", "fork exec command", err)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue