From 215dfd66fd13229aba803a9f0d0e0c553f2296be Mon Sep 17 00:00:00 2001 From: Lars Artmann Date: Wed, 9 Sep 2026 04:39:11 +0200 Subject: [PATCH 1/3] feat(model): observability signals, provider-executed flag, agent metadata --- internal/adapter/claudecode/adapter.go | 2 +- internal/adapter/codex/adapter.go | 2 +- internal/adapter/pi/adapter.go | 2 +- internal/model/agent.go | 1 + internal/model/agent_schema_test.go | 3 + internal/model/agent_test.go | 5 + internal/model/fuzz_test.go | 80 +++++++ internal/model/model.go | 33 ++- internal/model/schema_test.go | 238 +++++++++++++++++++++ internal/model/stats.go | 63 ++++-- internal/model/stats_test.go | 62 +++++- internal/model/trace_schema_parity_test.go | 110 ++++++++++ internal/model/trace_schema_test.go | 8 +- internal/server/server.go | 4 +- internal/server/server_test.go | 4 +- internal/textutil/truncate.go | 3 + internal/textutil/truncate_test.go | 2 + schema/trace.schema.json | 11 +- 18 files changed, 592 insertions(+), 41 deletions(-) create mode 100644 internal/model/fuzz_test.go create mode 100644 internal/model/schema_test.go create mode 100644 internal/model/trace_schema_parity_test.go diff --git a/internal/adapter/claudecode/adapter.go b/internal/adapter/claudecode/adapter.go index 878c973..e5692a8 100644 --- a/internal/adapter/claudecode/adapter.go +++ b/internal/adapter/claudecode/adapter.go @@ -261,7 +261,7 @@ func (a Adapter) Parse(path string) (*model.Trace, error) { } trace.Session.EventCount = len(trace.Events) // Claude Code tool results carry an is_error flag set by the harness. - trace.Stats = model.ComputeStats(trace, 0, model.ObservabilityExact) + trace.Stats = model.ComputeStats(trace, 0, model.ObservabilitySignals{Errors: model.ObservabilityExact}) if !recognized { return nil, fmt.Errorf("not a Claude Code session: %s", path) } diff --git a/internal/adapter/codex/adapter.go b/internal/adapter/codex/adapter.go index d874567..504d5f8 100644 --- a/internal/adapter/codex/adapter.go +++ b/internal/adapter/codex/adapter.go @@ -365,7 +365,7 @@ func (a Adapter) Parse(path string) (*model.Trace, error) { // Codex logs carry no structural error flag; failures are inferred from // output text, and inner commands of the js exec runner surface only what // the script chooses to print. - trace.Stats = model.ComputeStats(trace, 0, model.ObservabilityEstimated) + trace.Stats = model.ComputeStats(trace, 0, model.ObservabilitySignals{Errors: model.ObservabilityEstimated}) if !recognized { return nil, fmt.Errorf("not a Codex session: %s", path) } diff --git a/internal/adapter/pi/adapter.go b/internal/adapter/pi/adapter.go index 71286de..1772771 100644 --- a/internal/adapter/pi/adapter.go +++ b/internal/adapter/pi/adapter.go @@ -263,7 +263,7 @@ func (a Adapter) Parse(path string) (*model.Trace, error) { trace.Session.Title = sessionTitle(entries, firstUserText, path) trace.Session.EventCount = len(trace.Events) // pi tool results carry an isError flag set by the harness. - trace.Stats = model.ComputeStats(trace, 0, model.ObservabilityExact) + trace.Stats = model.ComputeStats(trace, 0, model.ObservabilitySignals{Errors: model.ObservabilityExact}) return trace, err } diff --git a/internal/model/agent.go b/internal/model/agent.go index ac76fd9..651b3e3 100644 --- a/internal/model/agent.go +++ b/internal/model/agent.go @@ -24,6 +24,7 @@ const ( AgentLinkMethodCodexParentThreadID = "codex-parent-thread-id" AgentLinkMethodClaudeToolUseID = "claude-tool-use-id" AgentLinkMethodClaudeSubagentsDirectory = "claude-subagents-directory" + AgentLinkMethodCrushAgentID = "crush-agent-id" AgentLinkMethodUnavailable = "unavailable" ) diff --git a/internal/model/agent_schema_test.go b/internal/model/agent_schema_test.go index 474d85a..d684f05 100644 --- a/internal/model/agent_schema_test.go +++ b/internal/model/agent_schema_test.go @@ -70,16 +70,19 @@ func TestAgentGraphSchemaAcceptsRepresentativeGraph(t *testing.T) { if err != nil { t.Fatal(err) } + var value any if err := json.Unmarshal(document, &value); err != nil { t.Fatal(err) } compiler := jsonschema.NewCompiler() + schema, err := compiler.Compile("../../schema/agent-graph.schema.json") if err != nil { t.Fatal(err) } + if err := schema.Validate(value); err != nil { t.Fatalf("representative AgentGraph violates schema: %v\n%s", err, document) } diff --git a/internal/model/agent_test.go b/internal/model/agent_test.go index 234b927..6b66680 100644 --- a/internal/model/agent_test.go +++ b/internal/model/agent_test.go @@ -23,13 +23,16 @@ func TestAgentNodeOmitsMainRelationshipFields(t *testing.T) { if err != nil { t.Fatal(err) } + var got map[string]any if err := json.Unmarshal(data, &got); err != nil { t.Fatal(err) } + if _, ok := got["parentId"]; ok { t.Fatalf("main node serialized parentId: %s", data) } + if _, ok := got["launchSeq"]; ok { t.Fatalf("main node serialized launchSeq: %s", data) } @@ -54,10 +57,12 @@ func TestAgentNodeSerializesZeroLaunchSeq(t *testing.T) { if err != nil { t.Fatal(err) } + var got map[string]any if err := json.Unmarshal(data, &got); err != nil { t.Fatal(err) } + if got["launchSeq"] != float64(0) { t.Fatalf("launchSeq = %#v, JSON = %s", got["launchSeq"], data) } diff --git a/internal/model/fuzz_test.go b/internal/model/fuzz_test.go new file mode 100644 index 0000000..0545681 --- /dev/null +++ b/internal/model/fuzz_test.go @@ -0,0 +1,80 @@ +//nolint:testpackage,goconst // Match the rest of the model test files (agent_schema_test, schema_test, etc). +package model + +import "testing" + +// FuzzComputeStats exercises ComputeStats with arbitrary trace +// shapes and observability signals. The invariant: the function must +// never panic and must always populate the observability grade — +// it can be Exact, Estimated, or Unavailable, but never empty or +// unrecognised. +func FuzzComputeStats(f *testing.F) { + f.Add(uint8(0), uint8(1), "exact", "exact") + f.Add(uint8(1), uint8(0), "", "estimated") + f.Add(uint8(2), uint8(2), "garbage", "") + f.Add(uint8(3), uint8(0), "exact", "") + f.Add(uint8(0), uint8(5), "", "") + f.Fuzz(func(t *testing.T, actionFlag, errorFlag uint8, errorSig, readsSig string) { + actions := []string{"search", "read", "edit", "exec", "verify"} + action := actions[int(actionFlag)%len(actions)] + + trace := &Trace{ + Events: []Event{ + { + Seq: 0, + Action: action, + IsError: errorFlag%2 == 1, + OutcomeKnown: errorFlag/2%2 == 1, + Targets: []Target{ + {Path: "a.go", Touch: "read", Weak: errorFlag%3 == 0}, + }, + ResultBytes: int(errorFlag), + }, + }, + } + + stats := ComputeStats(trace, int(actionFlag), ObservabilitySignals{ + Errors: errorSig, + Reads: readsSig, + }) + + // Grade must always be one of the three known values. + switch stats.Observability.Errors { + case ObservabilityExact, ObservabilityEstimated, ObservabilityUnavailable: + default: + t.Fatalf("unexpected errors grade %q", stats.Observability.Errors) + } + + switch stats.Observability.Reads { + case ObservabilityExact, ObservabilityEstimated, ObservabilityUnavailable: + default: + t.Fatalf("unexpected reads grade %q", stats.Observability.Reads) + } + }) +} + +// TestComputeStatsNilEvents pins the panic-free behaviour for a nil +// Events slice. Item 36 in the post-merge plan calls this out +// explicitly — without it, a downstream caller that builds a Trace{} +// by hand (e.g. during replay) can crash the server. +func TestComputeStatsNilEvents(t *testing.T) { + t.Parallel() + + stats := ComputeStats(&Trace{}, 0, ObservabilitySignals{Errors: ObservabilityExact}) + + if stats.Actions != (ActionCounts{}) { + t.Fatalf("actions = %#v, want zero", stats.Actions) + } + + if stats.ErrorRate != 0 { + t.Fatalf("errorRate = %v, want 0", stats.ErrorRate) + } + + if stats.EventsBeforeFirstEdit != 0 { + t.Fatalf("eventsBeforeFirstEdit = %d, want 0 (no events)", stats.EventsBeforeFirstEdit) + } + + if stats.Observability.Reads != ObservabilityUnavailable { + t.Fatalf("reads = %q, want unavailable (no reads)", stats.Observability.Reads) + } +} diff --git a/internal/model/model.go b/internal/model/model.go index 6c5b81e..aab60b7 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -61,6 +61,7 @@ type TraceSession struct { ID string `json:"id"` Harness string `json:"harness"` Model string `json:"model,omitempty"` + Provider string `json:"provider,omitempty"` Title string `json:"title,omitempty"` Cwd string `json:"cwd,omitempty"` Commit string `json:"commit,omitempty"` @@ -83,6 +84,10 @@ type Event struct { // result. IsError remains sufficient to identify failures in older traces. OutcomeKnown bool `json:"outcomeKnown,omitempty"` Summary string `json:"summary"` + // ProviderExecuted is true when the tool was executed server-side + // by the model provider rather than locally by the harness. Only + // the crush adapter sets this today. + ProviderExecuted bool `json:"providerExecuted,omitempty"` } type Target struct { @@ -99,9 +104,10 @@ type OutsideTouch struct { } type Mark struct { - Seq int `json:"seq"` - Type string `json:"type"` - Note string `json:"note,omitempty"` + Seq int `json:"seq"` + Type string `json:"type"` + Note string `json:"note,omitempty"` + Duration int `json:"duration,omitempty"` } type Stats struct { @@ -155,16 +161,29 @@ type ActionCounts struct { } type SessionMeta struct { - Key string `json:"key"` - ID string `json:"id"` - Harness string `json:"harness"` - Title string `json:"title,omitempty"` + Key string `json:"key"` + ID string `json:"id"` + Harness string `json:"harness"` + Title string `json:"title,omitempty"` + // Path is the deep-link handle the server uses to recover the + // session. For filesystem-backed harnesses (Claude Code, + // Codex) it is the on-disk JSONL file path. For database- + // backed harnesses (Crush, future Aider/Goose) it is a + // synthetic URI like "crush://session/" that the adapter + // resolves to a row in its storage. Callers that need to + // os.Stat the path must check for the synthetic scheme first. Path string `json:"path"` Cwd string `json:"cwd,omitempty"` Model string `json:"model,omitempty"` + Provider string `json:"provider,omitempty"` GitBranch string `json:"gitBranch,omitempty"` StartedAt string `json:"startedAt,omitempty"` EndedAt string `json:"endedAt,omitempty"` + // PromptTokens and CompletionTokens are the session-level token + // economics from the harness's session table, when available. + PromptTokens int64 `json:"promptTokens,omitempty"` + CompletionTokens int64 `json:"completionTokens,omitempty"` + Cost float64 `json:"cost,omitempty"` // EventCount and UserTurns together are the cheap staleness signal for // report badges: user messages land on marks, not events, so the count // alone misses exactly the follow-ups that matter most. diff --git a/internal/model/schema_test.go b/internal/model/schema_test.go new file mode 100644 index 0000000..5cfb775 --- /dev/null +++ b/internal/model/schema_test.go @@ -0,0 +1,238 @@ +package model + +import ( + "encoding/json" + "testing" + + "github.com/santhosh-tekuri/jsonschema/v6" +) + +// validateAgainstSchema marshals v to JSON, compiles the named schema file, +// and reports a fatal error if the JSON does not validate. +func validateAgainstSchema(t *testing.T, schemaPath string, v any) { + t.Helper() + + document, err := json.Marshal(v) + if err != nil { + t.Fatal(err) + } + + var value any + if err := json.Unmarshal(document, &value); err != nil { + t.Fatal(err) + } + + compiler := jsonschema.NewCompiler() + + schema, err := compiler.Compile(schemaPath) + if err != nil { + t.Fatal(err) + } + + if err := schema.Validate(value); err != nil { + t.Fatalf("%s schema validation failed: %v\nJSON: %s", schemaPath, err, document) + } +} + +func TestTraceSchemaAcceptsRepresentativeTrace(t *testing.T) { + tr := Trace{ + Version: 1, + Session: TraceSession{ + ID: "test-session", + Harness: "crush", + Model: "claude-sonnet-4", + EventCount: 3, + }, + Events: []Event{ + { + Seq: 1, + Tool: "read", + Action: "read", + Summary: "read main.go", + Targets: []Target{{Path: "main.go", Touch: "read"}}, + }, + { + Seq: 2, + Tool: "edit", + Action: "edit", + Summary: "edit utils.go", + Targets: []Target{{Path: "util.go", Touch: "edit"}}, + }, + {Seq: 3, Tool: "bash", Action: "exec", Summary: "go test", Targets: []Target{}}, + }, + Marks: []Mark{ + {Seq: 1, Type: "user-message"}, + {Seq: 2, Type: "thinking", Duration: 5}, + }, + Stats: Stats{ + FilesInRepo: 10, + Fovea: 3, + Parafovea: 2, + Edited: 1, + EventsBeforeFirstEdit: 1, + RegressionRate: 0.0, + ErrorRate: 0.33, + Actions: ActionCounts{Read: 1, Edit: 1, Exec: 1, Other: 0}, + Errors: ActionCounts{}, + Observability: Observability{Reads: "exact", Errors: "estimated"}, + ResultBytes: 1024, + UserTurns: 1, + }, + } + validateAgainstSchema(t, "../../schema/trace.schema.json", tr) +} + +func TestReportSchemaAcceptsRepresentativeReport(t *testing.T) { + rep := Report{ + Version: 1, + Session: ReportSession{ + ID: "test-session", + Harness: "crush", + Model: "claude-sonnet-4", + EventCount: 10, + UserTurns: 2, + }, + Judge: ReportJudge{ + CLI: "claude", + Model: "claude-sonnet-4", + PromptVersion: 1, + GeneratedAt: "2026-08-04T12:00:00Z", + InputDigest: "abc123", + }, + TaskSummary: "Fix a bug in the parser", + Dimensions: []ReportDimension{ + { + Name: DimensionExploration, + Verdict: VerdictGood, + Findings: []ReportFinding{ + {Claim: "Explored the right files", Severity: SeverityInfo, EvidenceSeqs: []int{1, 2}}, + }, + }, + { + Name: DimensionScope, + Verdict: VerdictWarning, + Findings: []ReportFinding{ + {Claim: "Edited an unrelated file", Severity: SeverityWarning, EvidenceSeqs: []int{5}}, + }, + }, + { + Name: DimensionWandering, + Verdict: VerdictGood, + Findings: []ReportFinding{ + {Claim: "Stayed focused", Severity: SeverityInfo, EvidenceSeqs: []int{3}}, + }, + }, + { + Name: DimensionVerification, + Verdict: VerdictProblem, + Findings: []ReportFinding{ + {Claim: "No verification step", Severity: SeverityProblem, EvidenceSeqs: []int{8}}, + }, + }, + }, + Narrative: "The agent explored well but skipped verification.", + } + validateAgainstSchema(t, "../../schema/report.schema.json", rep) +} + +func TestReportSchemaAcceptsReportWithRubric(t *testing.T) { + rep := Report{ + Version: 1, + Session: ReportSession{ + ID: "rubric-test", + Harness: "codex", + EventCount: 5, + }, + Judge: ReportJudge{ + CLI: "codex", + PromptVersion: 1, + RubricPromptVersion: 1, + GeneratedAt: "2026-08-04T12:00:00Z", + InputDigest: "def456", + }, + TaskSummary: "Add a feature", + Dimensions: []ReportDimension{ + { + Name: DimensionExploration, + Verdict: VerdictGood, + Findings: []ReportFinding{ + {Claim: "good", Severity: SeverityInfo, EvidenceSeqs: []int{1}}, + }, + }, + { + Name: DimensionScope, + Verdict: VerdictGood, + Findings: []ReportFinding{{Claim: "good", Severity: SeverityInfo, EvidenceSeqs: []int{1}}}, + }, + { + Name: DimensionWandering, + Verdict: VerdictGood, + Findings: []ReportFinding{{Claim: "good", Severity: SeverityInfo, EvidenceSeqs: []int{1}}}, + }, + { + Name: DimensionVerification, + Verdict: VerdictGood, + Findings: []ReportFinding{{Claim: "good", Severity: SeverityInfo, EvidenceSeqs: []int{1}}}, + }, + }, + Rubric: &Rubric{ + Status: RubricStatusScored, + Tasks: []RubricTask{ + { + Title: "Add the feature", + AnchorUserMessages: []int{1}, + Criteria: []RubricCriterion{ + { + ID: "crit-1", + Title: "Implementation correct", + Coverage: CoverageSufficient, + Verdict: VerdictGood, + Findings: []ReportFinding{ + {Claim: "correct", Severity: SeverityInfo, EvidenceSeqs: []int{1}}, + }, + }, + }, + }, + }, + }, + Narrative: "Done.", + } + validateAgainstSchema(t, "../../schema/report.schema.json", rep) +} + +func TestCityMapSchemaAcceptsRepresentativeCityMap(t *testing.T) { + cm := CityMap{ + Version: 1, + Repo: RepoMeta{ + Root: "/tmp/repo", + Commit: "abc123", + Dirty: false, + GeneratedAt: "2026-08-04T12:00:00Z", + }, + Files: []CityFile{ + { + ID: 0, + Path: "main.go", + Dir: "", + Lines: 100, + Bytes: 2048, + Lang: "go", + Rect: Rect{X: 0, Z: 0, W: 0.5, D: 0.5}, + }, + { + ID: 1, + Path: "util.go", + Dir: "", + Lines: 50, + Bytes: 1024, + Lang: "go", + Rect: Rect{X: 0.5, Z: 0, W: 0.5, D: 0.5}, + }, + }, + Dirs: []CityDir{ + {Path: "pkg", Depth: 1, Rect: Rect{X: 0, Z: 0, W: 1, D: 1}, FileCount: 2, Lines: 150}, + }, + Layout: LayoutMeta{Algorithm: "squarified", Weight: "lines"}, + } + validateAgainstSchema(t, "../../schema/citymap.schema.json", cm) +} diff --git a/internal/model/stats.go b/internal/model/stats.go index 1944bf8..482ef86 100644 --- a/internal/model/stats.go +++ b/internal/model/stats.go @@ -1,10 +1,24 @@ package model -// ComputeStats derives session facts from a parsed trace. errorSignal is the -// adapter's grade for its own error detection (ObservabilityExact when the -// source log flags failures structurally, ObservabilityEstimated when they -// are inferred from output text); an empty value falls back to estimated. -func ComputeStats(trace *Trace, filesInRepo int, errorSignal string) Stats { +// ObservabilitySignals carries adapter-supplied observability grades into +// ComputeStats, preventing positional-parameter explosion as more signals +// are added. Each field overrides the derived grade when non-empty. +type ObservabilitySignals struct { + // Errors is the adapter's grade for its own error detection + // (ObservabilityExact when the source log flags failures structurally, + // ObservabilityEstimated when they are inferred from output text). + // Empty falls back to estimated. + Errors string + // Reads overrides the reads grade when non-empty (used by adapters + // that have a structural read_files table); empty falls back to + // deriving from weak target flags. + Reads string +} + +// ComputeStats derives session facts from a parsed trace. The signals +// parameter carries adapter-supplied observability grades; empty fields +// are derived from the trace data. +func ComputeStats(trace *Trace, filesInRepo int, signals ObservabilitySignals) Stats { state := map[string]string{} lastReadVersion := map[string]int{} editVersion := map[string]int{} @@ -100,26 +114,45 @@ func ComputeStats(trace *Trace, filesInRepo int, errorSignal string) Stats { if len(trace.Events) > 0 { stats.ErrorRate = float64(errors) / float64(len(trace.Events)) } - // Weak read targets are inferred from command text, so any of them in the - // mix downgrades the re-read rate; no reads at all leaves it undefined. - switch { - case readEvents == 0: - stats.Observability.Reads = ObservabilityUnavailable - case weakReads == 0: - stats.Observability.Reads = ObservabilityExact - default: - stats.Observability.Reads = ObservabilityEstimated + // The reads grade: prefer the adapter-supplied signal (structural + // truth from a read_files table); fall back to deriving from weak + // target flags when the adapter did not supply one. Unrecognized + // override values are treated as absent so adapter bugs can never + // leak an invalid grade into stats or the exported schema. + readsSignal := signals.Reads + if !knownObservabilityGrade(readsSignal) { + switch { + case readEvents == 0: + readsSignal = ObservabilityUnavailable + case weakReads == 0: + readsSignal = ObservabilityExact + default: + readsSignal = ObservabilityEstimated + } } - if errorSignal == "" { + stats.Observability.Reads = readsSignal + errorSignal := signals.Errors + if !knownObservabilityGrade(errorSignal) { errorSignal = ObservabilityEstimated } + if errorSignal == ObservabilityExact && unknownOutcomes { errorSignal = ObservabilityEstimated } + stats.Observability.Errors = errorSignal return stats } +func knownObservabilityGrade(v string) bool { + switch v { + case ObservabilityExact, ObservabilityEstimated, ObservabilityUnavailable: + return true + default: + return false + } +} + func countAction(counts *ActionCounts, action string) { switch action { case "search": diff --git a/internal/model/stats_test.go b/internal/model/stats_test.go index 574f2e6..3343b46 100644 --- a/internal/model/stats_test.go +++ b/internal/model/stats_test.go @@ -22,23 +22,28 @@ func TestComputeStatsFactCounters(t *testing.T) { }, } - stats := ComputeStats(trace, 10, ObservabilityExact) + stats := ComputeStats(trace, 10, ObservabilitySignals{Errors: ObservabilityExact}) if stats.Actions != (ActionCounts{Search: 1, Read: 1, Edit: 4, Exec: 1, Verify: 1}) { t.Fatalf("actions = %#v", stats.Actions) } + if stats.Errors != (ActionCounts{Edit: 1, Exec: 1}) { t.Fatalf("errors = %#v", stats.Errors) } + if stats.MaxEditsPerFile != 3 || stats.ChurnFiles != 1 { t.Fatalf("maxEditsPerFile = %d, churnFiles = %d", stats.MaxEditsPerFile, stats.ChurnFiles) } + if stats.UserTurns != 2 || stats.Compactions != 1 || stats.Subagents != 1 { t.Fatalf("marks = %d/%d/%d", stats.UserTurns, stats.Compactions, stats.Subagents) } + if stats.ResultBytes != 30 { t.Fatalf("resultBytes = %d", stats.ResultBytes) } + if stats.EditsAfterLastVerify != 1 { t.Fatalf("editsAfterLastVerify = %d", stats.EditsAfterLastVerify) } @@ -51,7 +56,11 @@ func TestComputeStatsEditsAfterLastVerifyWithoutVerify(t *testing.T) { {Seq: 1, Action: "edit", Targets: []Target{{Path: "b.go", Touch: "edit"}}}, }, } - if stats := ComputeStats(trace, 0, ObservabilityExact); stats.EditsAfterLastVerify != 2 { + if stats := ComputeStats( + trace, + 0, + ObservabilitySignals{Errors: ObservabilityExact}, + ); stats.EditsAfterLastVerify != 2 { t.Fatalf("editsAfterLastVerify = %d", stats.EditsAfterLastVerify) } } @@ -71,18 +80,53 @@ func TestComputeStatsObservability(t *testing.T) { wantErrors string }{ {"strong reads are exact", []Event{strongRead}, ObservabilityExact, ObservabilityExact, ObservabilityExact}, - {"any weak read downgrades", []Event{strongRead, weakRead}, ObservabilityExact, ObservabilityEstimated, ObservabilityExact}, - {"unknown outcome downgrades exact errors", []Event{strongRead, pending}, ObservabilityExact, ObservabilityExact, ObservabilityEstimated}, - {"legacy failure remains known", []Event{legacyFailure}, ObservabilityExact, ObservabilityUnavailable, ObservabilityExact}, - {"no reads is unavailable", []Event{hitOnly}, ObservabilityEstimated, ObservabilityUnavailable, ObservabilityEstimated}, - {"empty error signal falls back to estimated", []Event{strongRead}, "", ObservabilityExact, ObservabilityEstimated}, + { + "any weak read downgrades", + []Event{strongRead, weakRead}, + ObservabilityExact, + ObservabilityEstimated, + ObservabilityExact, + }, + { + "unknown outcome downgrades exact errors", + []Event{strongRead, pending}, + ObservabilityExact, + ObservabilityExact, + ObservabilityEstimated, + }, + { + "legacy failure remains known", + []Event{legacyFailure}, + ObservabilityExact, + ObservabilityUnavailable, + ObservabilityExact, + }, + { + "no reads is unavailable", + []Event{hitOnly}, + ObservabilityEstimated, + ObservabilityUnavailable, + ObservabilityEstimated, + }, + { + "empty error signal falls back to estimated", + []Event{strongRead}, + "", + ObservabilityExact, + ObservabilityEstimated, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - stats := ComputeStats(&Trace{Events: tt.events}, 0, tt.errorSignal) + stats := ComputeStats(&Trace{Events: tt.events}, 0, ObservabilitySignals{Errors: tt.errorSignal}) if stats.Observability.Reads != tt.wantReads || stats.Observability.Errors != tt.wantErrors { - t.Fatalf("observability = %#v, want reads %q errors %q", stats.Observability, tt.wantReads, tt.wantErrors) + t.Fatalf( + "observability = %#v, want reads %q errors %q", + stats.Observability, + tt.wantReads, + tt.wantErrors, + ) } }) } diff --git a/internal/model/trace_schema_parity_test.go b/internal/model/trace_schema_parity_test.go new file mode 100644 index 0000000..cbb38e2 --- /dev/null +++ b/internal/model/trace_schema_parity_test.go @@ -0,0 +1,110 @@ +//nolint:testpackage // Match the rest of the model test files (agent_schema_test, schema_test, etc). +package model + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +// TestTraceSchemaParityWithTypesScript walks the trace JSON schema +// and asserts that every required event property has a counterpart +// in the frontend's TraceEvent declaration. The two evolve +// independently — Go changes via go.mod + regen, TS changes via +// web/package.json — so a parity test catches drift before a +// backend change ships without the frontend matching. +// +// The check is deliberately one-way: schema properties must exist +// in TS. TS-only properties are allowed (the frontend can carry +// view-layer fields the backend never emits) but the schema is the +// source of truth for what the API returns. +func TestTraceSchemaParityWithTypesScript(t *testing.T) { + t.Parallel() + + repoRoot := filepath.Join("..", "..") + schemaPath := filepath.Join(repoRoot, "schema", "trace.schema.json") + typesPath := filepath.Join(repoRoot, "web", "src", "types.ts") + + schemaBytes, err := os.ReadFile(schemaPath) + if err != nil { + t.Fatalf("read %s: %v", schemaPath, err) + } + + typesBytes, err := os.ReadFile(typesPath) + if err != nil { + t.Fatalf("read %s: %v", typesPath, err) + } + + var schema struct { + Properties map[string]struct { + Properties map[string]struct{} `json:"properties"` + Items struct { + Properties map[string]struct{} `json:"properties"` + Required []string `json:"required"` + } `json:"items"` + } `json:"properties"` + } + if err := json.Unmarshal(schemaBytes, &schema); err != nil { + t.Fatalf("parse schema: %v", err) + } + + events := schema.Properties["events"].Items + + var missing []string + + for _, name := range events.Required { + if _, ok := lookupTSField(typesBytes, "TraceEvent", name); !ok { + missing = append(missing, name) + } + } + + if len(missing) > 0 { + t.Fatalf("trace.schema.json event fields missing from web/src/types.ts TraceEvent: %v", missing) + } +} + +// lookupTSField scans a TypeScript file for an interface field, matching +// the camelCase name inside `interface { ... }` (with an +// optional `export ` prefix). The scan is line-based: it returns true +// when the field name appears at the start of a line that has been +// recognised as a property declaration. +func lookupTSField(source []byte, interfaceName, field string) (struct{}, bool) { + var ( + depth int + inBlock bool + lines = strings.Split(string(source), "\n") + ) + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if !inBlock { + if strings.HasPrefix(trimmed, "interface "+interfaceName) || + strings.HasPrefix(trimmed, "export interface "+interfaceName) { + inBlock = true + } + + continue + } + + // crude brace tracking: each '{' adds, each '}' subtracts + for _, r := range trimmed { + switch r { + case '{': + depth++ + case '}': + depth-- + } + } + + if depth <= 0 && strings.Contains(trimmed, "}") { + return struct{}{}, false + } + + if strings.HasPrefix(trimmed, field+":") || strings.HasPrefix(trimmed, field+"?:") { + return struct{}{}, true + } + } + + return struct{}{}, false +} diff --git a/internal/model/trace_schema_test.go b/internal/model/trace_schema_test.go index 6c88958..3d704e3 100644 --- a/internal/model/trace_schema_test.go +++ b/internal/model/trace_schema_test.go @@ -42,25 +42,31 @@ func TestTraceSchemaAcceptsOutcomeCertainty(t *testing.T) { }, }, Marks: []Mark{}, - Stats: ComputeStats(&Trace{}, 0, ObservabilityEstimated), + Stats: ComputeStats(&Trace{}, 0, ObservabilitySignals{}), } + document, err := json.Marshal(trace) if err != nil { t.Fatal(err) } + var value any if err := json.Unmarshal(document, &value); err != nil { t.Fatal(err) } + events := value.(map[string]any)["events"].([]any) if _, found := events[2].(map[string]any)["outcomeKnown"]; found { t.Fatal("unknown outcome serialized outcomeKnown") } + compiler := jsonschema.NewCompiler() + schema, err := compiler.Compile("../../schema/trace.schema.json") if err != nil { t.Fatal(err) } + if err := schema.Validate(value); err != nil { t.Fatalf("trace with outcome certainty violates schema: %v\n%s", err, document) } diff --git a/internal/server/server.go b/internal/server/server.go index bd79d65..eed171a 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -886,7 +886,7 @@ func (s *Server) loadTraceAndMap(meta model.SessionMeta) (*model.Trace, *model.C } // Recompute with the citymap's file count, carrying over the adapter's // grade for its error signal — the recount cannot re-derive it. - trace.Stats = model.ComputeStats(trace, repoFileCount(city), trace.Stats.Observability.Errors) + trace.Stats = model.ComputeStats(trace, repoFileCount(city), model.ObservabilitySignals{Errors: trace.Stats.Observability.Errors}) return trace, city, nil } @@ -1030,7 +1030,7 @@ func traceAgainstCity(trace *model.Trace, city *model.CityMap) *model.Trace { } clone.Marks = append([]model.Mark{}, trace.Marks...) assignFileIDs(&clone, city) - clone.Stats = model.ComputeStats(&clone, repoFileCount(city), trace.Stats.Observability.Errors) + clone.Stats = model.ComputeStats(&clone, repoFileCount(city), model.ObservabilitySignals{Errors: trace.Stats.Observability.Errors}) return &clone } diff --git a/internal/server/server_test.go b/internal/server/server_test.go index e143ff9..a3b7e37 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -1217,7 +1217,7 @@ func newAgentAPIServer(t *testing.T) (*Server, *agentAPISource) { Session: model.TraceSession{ID: id, Harness: "agent-api", Cwd: meta.Cwd, Path: meta.Path, EventCount: len(events)}, Events: events, Marks: []model.Mark{}, - Stats: model.ComputeStats(&model.Trace{Events: events}, 1, model.ObservabilityExact), + Stats: model.ComputeStats(&model.Trace{Events: events}, 1, model.ObservabilitySignals{Errors: model.ObservabilityExact}), } } zeroMeta := metas[filepath.Clean(paths["child-zero"])] @@ -1226,7 +1226,7 @@ func newAgentAPIServer(t *testing.T) (*Server, *agentAPISource) { Session: model.TraceSession{ID: "child-zero", Harness: "agent-api", Cwd: zeroMeta.Cwd, Path: zeroMeta.Path, EventCount: 0}, Events: []model.Event{}, Marks: []model.Mark{}, - Stats: model.ComputeStats(&model.Trace{Events: []model.Event{}}, 1, model.ObservabilityExact), + Stats: model.ComputeStats(&model.Trace{Events: []model.Event{}}, 1, model.ObservabilitySignals{Errors: model.ObservabilityExact}), } graphs := map[string]*model.AgentGraph{ "root-a": { diff --git a/internal/textutil/truncate.go b/internal/textutil/truncate.go index 869bf02..495bb61 100644 --- a/internal/textutil/truncate.go +++ b/internal/textutil/truncate.go @@ -18,6 +18,7 @@ func TruncateRunes(text string, limit int, marker string) string { if utf8.ValidString(text) { return text } + return string(runes) } @@ -25,6 +26,8 @@ func TruncateRunes(text string, limit int, marker string) string { if len(markerRunes) > limit { markerRunes = markerRunes[:limit] } + contentLimit := limit - len(markerRunes) + return string(runes[:contentLimit]) + string(markerRunes) } diff --git a/internal/textutil/truncate_test.go b/internal/textutil/truncate_test.go index 067bb87..1d62fc8 100644 --- a/internal/textutil/truncate_test.go +++ b/internal/textutil/truncate_test.go @@ -28,9 +28,11 @@ func TestTruncateRunes(t *testing.T) { if got != tt.want { t.Fatalf("TruncateRunes() = %q, want %q", got, tt.want) } + if !utf8.ValidString(got) { t.Fatalf("TruncateRunes() returned invalid UTF-8: %q", got) } + if gotRunes := len([]rune(got)); gotRunes > tt.limit { t.Fatalf("TruncateRunes() returned %d runes, limit %d", gotRunes, tt.limit) } diff --git a/schema/trace.schema.json b/schema/trace.schema.json index 1103e62..2bf6830 100644 --- a/schema/trace.schema.json +++ b/schema/trace.schema.json @@ -13,6 +13,7 @@ "id": { "type": "string" }, "harness": { "type": "string" }, "model": { "type": "string" }, + "provider": { "type": "string" }, "title": { "type": "string" }, "cwd": { "type": "string" }, "commit": { "type": "string" }, @@ -52,7 +53,8 @@ "resultBytes": { "type": "integer", "minimum": 0 }, "isError": { "type": "boolean" }, "outcomeKnown": { "type": "boolean" }, - "summary": { "type": "string" } + "summary": { "type": "string" }, + "providerExecuted": { "type": "boolean" } }, "additionalProperties": false } @@ -64,10 +66,15 @@ "required": ["seq", "type"], "properties": { "seq": { "type": "integer", "minimum": 0 }, - "type": { "enum": ["compaction", "user-message", "subagent"] }, + "type": { "enum": ["compaction", "user-message", "subagent", "thinking", "finish-reason", "model-switch"] }, "note": { "type": "string", "description": "Free-form context for the mark: user-message marks carry the message text truncated to 2000 runes; subagent marks carry the tool name" + }, + "duration": { + "type": "integer", + "minimum": 0, + "description": "Wall-clock duration in seconds from the message's created_at to finished_at; present on thinking and finish-reason marks when the harness records it" } }, "additionalProperties": false From 74956d256a9d1c09f1647172226bc4586e53ddda Mon Sep 17 00:00:00 2001 From: Lars Artmann Date: Wed, 9 Sep 2026 04:39:32 +0200 Subject: [PATCH 2/3] refactor(adapter): shared helpers + tool-call pairing contract --- go.mod | 7 +- go.sum | 2 + internal/adapter/adapter.go | 656 ++++++++++++++++-- internal/adapter/adapter_test.go | 377 +++++++++- internal/adapter/agent_launch.go | 93 +++ internal/adapter/claudecode/adapter.go | 55 +- internal/adapter/claudecode/adapter_test.go | 103 ++- internal/adapter/claudecode/agents.go | 103 ++- internal/adapter/claudecode/agents_test.go | 53 +- internal/adapter/claudecode/diagnostics.go | 7 + .../adapter/claudecode/summary_inputs_test.go | 2 + internal/adapter/codex/adapter.go | 116 ++-- internal/adapter/codex/adapter_test.go | 245 ++++++- internal/adapter/codex/agents.go | 234 ++++--- internal/adapter/codex/agents_test.go | 58 +- internal/adapter/codex/diagnostics.go | 31 + internal/adapter/codex/summary_inputs_test.go | 1 + internal/adapter/fuzz_test.go | 24 + internal/adapter/helpers_test.go | 238 +++++++ internal/adapter/home.go | 25 + internal/adapter/input.go | 50 ++ internal/adapter/pi/adapter.go | 124 +++- internal/adapter/pi/adapter_test.go | 109 ++- internal/adapter/pi/diagnostics.go | 7 + internal/adapter/property_test.go | 119 ++++ internal/adapter/title.go | 32 + 26 files changed, 2509 insertions(+), 362 deletions(-) create mode 100644 internal/adapter/agent_launch.go create mode 100644 internal/adapter/claudecode/diagnostics.go create mode 100644 internal/adapter/codex/diagnostics.go create mode 100644 internal/adapter/fuzz_test.go create mode 100644 internal/adapter/helpers_test.go create mode 100644 internal/adapter/home.go create mode 100644 internal/adapter/input.go create mode 100644 internal/adapter/pi/diagnostics.go create mode 100644 internal/adapter/property_test.go create mode 100644 internal/adapter/title.go diff --git a/go.mod b/go.mod index 48b25a4..6ce9903 100644 --- a/go.mod +++ b/go.mod @@ -1,7 +1,10 @@ module github.com/cosmtrek/mindwalk -go 1.25 +go 1.26.5 require github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 -require golang.org/x/text v0.14.0 // indirect +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + golang.org/x/text v0.14.0 // indirect +) diff --git a/go.sum b/go.sum index 8fee20f..f19ed67 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= diff --git a/internal/adapter/adapter.go b/internal/adapter/adapter.go index 7a342ea..49a8538 100644 --- a/internal/adapter/adapter.go +++ b/internal/adapter/adapter.go @@ -11,10 +11,12 @@ import ( "path/filepath" "regexp" "sort" + "strconv" "strings" "github.com/cosmtrek/mindwalk/internal/model" "github.com/cosmtrek/mindwalk/internal/textutil" + "github.com/dustin/go-humanize/english" ) type Source interface { @@ -25,6 +27,88 @@ type Source interface { Parse(path string) (*model.Trace, error) } +// Closer is implemented by adapters that hold reusable resources +// (database connection pools, file handles) that must be released on +// shutdown. Adapters that do not implement Close rely on garbage +// collection. +type Closer interface { + Close() error +} + +// DiagnosticCheck is the result of a single health probe. Status is +// "ok", "warn", or "error"; Detail carries a human-readable explanation +// or the resolved value (e.g. the data-directory path). +type DiagnosticCheck struct { + Name string + Status string + Detail string +} + +// DiagnosticsSource is optionally implemented by adapters that can run +// deeper health checks beyond listing sessions. The doctor command +// discovers this via type assertion. +type DiagnosticsSource interface { + Diagnostics() []DiagnosticCheck +} + +// FilesystemDiagnostics builds standard health checks for a +// filesystem-based adapter: the data directory exists and is readable, +// and a count of session files matching suffix. Adapters call it from +// their Diagnostics() method and may append adapter-specific checks. +func FilesystemDiagnostics(dir, suffix string) []DiagnosticCheck { + var checks []DiagnosticCheck + if dir == "" { + checks = append(checks, DiagnosticCheck{ + Name: "data-dir", + Status: "error", + Detail: "no data directory configured", + }) + + return checks + } + + if !ReadableDir(dir) { + checks = append(checks, DiagnosticCheck{ + Name: "data-dir", + Status: "warn", + Detail: fmt.Sprintf("directory %s does not exist or is not readable", dir), + }) + + return checks + } + + checks = append(checks, DiagnosticCheck{ + Name: "data-dir", + Status: "ok", + Detail: dir, + }) + count := countFilesBySuffix(dir, suffix) + checks = append(checks, DiagnosticCheck{ + Name: "session-files", + Status: "ok", + Detail: fmt.Sprintf("%d %s file(s) found", count, suffix), + }) + + return checks +} + +func countFilesBySuffix(dir, suffix string) int { + count := 0 + _ = filepath.WalkDir(dir, func(_ string, entry os.DirEntry, err error) error { + if err != nil || entry.IsDir() { + return nil + } + + if strings.HasSuffix(entry.Name(), suffix) { + count++ + } + + return nil + }) + + return count +} + type AgentGraphSource interface { AgentGraphInputs(root model.SessionMeta, catalog []model.SessionMeta) ([]string, error) BuildAgentGraph(root model.SessionMeta, catalog []model.SessionMeta) (*model.AgentGraph, error) @@ -38,14 +122,88 @@ type SummarySidecarSource interface { SummaryInputs(path string) []string } +// IsAgentGraphSource reports whether a source implements the agent-graph +// capability. Used by the server's adapter-status endpoint. +func IsAgentGraphSource(src Source) bool { + _, ok := src.(AgentGraphSource) + + return ok +} + +// UserHomeDir returns the current user's home directory, or "" when +// the OS cannot resolve one (containers, unusual platforms). Callers +// append a relative path and treat the empty return as "do not probe". +func UserHomeDir() string { + home, err := os.UserHomeDir() + if err != nil { + return "" + } + + return home +} + +// HomePath joins an absolute path under the user's home directory, +// returning "" when the home directory cannot be resolved. Callers use +// the empty return as a "not configured" signal — they never fall back +// to a synthesized path. +func HomePath(parts ...string) string { + home := UserHomeDir() + if home == "" { + return "" + } + + return filepath.Join(append([]string{home}, parts...)...) +} + +// OpenFile opens a session log for reading and returns the file plus a +// close function callers can `defer` inline. Returning the file plus a +// deferred close was repeated across every JSONL adapter; this helper +// keeps the common two-line ceremony in one place and makes the +// "open then read" intent visible at every call site. +func OpenFile(path string) (*os.File, func(), error) { + f, err := os.Open(path) + if err != nil { + return nil, nil, err + } + + return f, func() { _ = f.Close() }, nil +} + +// ReadableDir reports whether dir is a directory the caller can read. +// Adapters use it to short-circuit ListSessions on missing ~/.harness +// trees before walking them. +func ReadableDir(dir string) bool { + if dir == "" { + return false + } + + info, err := os.Stat(dir) + + return err == nil && info.IsDir() +} + +// NotRecognizedErr builds the error a JSONL adapter returns when it +// walked the file but never saw a recognizable event for its harness. +// Centralizing the format keeps the user-visible message and any future +// flag (e.g. logging the candidate path) consistent across adapters. +func NotRecognizedErr(harness, path string) error { + return fmt.Errorf("not a %s session: %s", harness, path) +} + type ToolCall struct { ID string Name string Input map[string]any Timestamp string + // ProviderExecuted is true when the tool was executed server-side + // by the model provider (e.g. computer use) rather than locally. + ProviderExecuted bool } type ToolResult struct { + // ToolCallID pairs a result with its ToolCall.ID when the harness + // records the linkage; adapters that pair positionally leave it empty. + ToolCallID string Content string IsError bool OutcomeKnown bool @@ -55,21 +213,33 @@ type ToolResult struct { // session ID. Codex resume rollouts can share an ID across multiple files, so // IDs are display metadata rather than safe routing or cache keys. func SessionKey(harness, path string) string { + path = NormalizePath(path) + sum := sha256.Sum256([]byte(harness + "\x00" + path)) + + return fmt.Sprintf("%s-%x", harness, sum[:12]) +} + +// NormalizePath returns the absolute, cleaned form of path. Callers +// that want to route or hash a path use this so equivalent inputs +// ("./foo", "foo", absolute paths to the same file) collapse to the +// same string. +func NormalizePath(path string) string { if abs, err := filepath.Abs(path); err == nil { path = abs } - path = filepath.Clean(path) - sum := sha256.Sum256([]byte(harness + "\x00" + path)) - return fmt.Sprintf("%s-%x", harness, sum[:12]) + + return filepath.Clean(path) } func AgentNodeID(harness, rootSessionKey, actorIdentity string) string { sum := sha256.Sum256([]byte(harness + "\x00" + rootSessionKey + "\x00" + actorIdentity)) + return fmt.Sprintf("agt_%x", sum[:12]) } func AgentInstructionPreview(text string) string { text = strings.Join(strings.Fields(text), " ") + return textutil.TruncateRunes(text, 240, "…") } @@ -84,26 +254,35 @@ func OrderAgentNodesPreorder(nodes []model.AgentNode) []model.AgentNode { children := make(map[string][]model.AgentNode) roots := make([]model.AgentNode, 0, 1) + for _, node := range nodes { if node.Kind == model.AgentKindMain || node.ParentID == "" || !known[node.ParentID] { roots = append(roots, node) + continue } + children[node.ParentID] = append(children[node.ParentID], node) } + sortAgentNodeSiblings(roots) + for parentID := range children { sortAgentNodeSiblings(children[parentID]) } ordered := make([]model.AgentNode, 0, len(nodes)) visited := make(map[string]bool, len(nodes)) + var visit func(model.AgentNode) + visit = func(node model.AgentNode) { if visited[node.ID] { return } + visited[node.ID] = true + ordered = append(ordered, node) for _, child := range children[node.ID] { visit(child) @@ -115,9 +294,11 @@ func OrderAgentNodesPreorder(nodes []model.AgentNode) []model.AgentNode { remaining := append([]model.AgentNode(nil), nodes...) sortAgentNodeSiblings(remaining) + for _, node := range remaining { visit(node) } + return ordered } @@ -127,15 +308,19 @@ func sortAgentNodeSiblings(nodes []model.AgentNode) { if left.Kind == model.AgentKindMain || right.Kind == model.AgentKindMain { return left.Kind == model.AgentKindMain && right.Kind != model.AgentKindMain } + if (left.LaunchSeq == nil) != (right.LaunchSeq == nil) { return left.LaunchSeq != nil } + if left.LaunchSeq != nil && *left.LaunchSeq != *right.LaunchSeq { return *left.LaunchSeq < *right.LaunchSeq } + if left.Label != right.Label { return left.Label < right.Label } + return left.ID < right.ID }) } @@ -150,6 +335,7 @@ const userMessageNoteLimit = 2000 // promises the note never exceeds userMessageNoteLimit runes in total. func UserMessageNote(text string) string { text = strings.TrimSpace(text) + return textutil.TruncateRunes(text, userMessageNoteLimit, "…") } @@ -171,6 +357,7 @@ func InjectedUserMessage(text string) bool { if strings.HasPrefix(text, "# AGENTS.md instructions") { return true } + return strings.HasPrefix(text, "<") && strings.HasSuffix(text, ">") } @@ -184,10 +371,12 @@ func ReadJSONLines(r io.Reader, visit func([]byte)) error { visit(line) } } + if err != nil { if err == io.EOF { return nil } + return err } } @@ -195,21 +384,24 @@ func ReadJSONLines(r io.Reader, visit func([]byte)) error { func BuildEvent(trace *model.Trace, call ToolCall, result ToolResult) model.Event { action := actionFor(call.Name, call.Input, result.Content) + targets, outside := targetsFor(trace.Session.Cwd, call.Name, call.Input, result.Content) if targets == nil { targets = []model.Target{} } + return model.Event{ - Seq: len(trace.Events), - Timestamp: call.Timestamp, - Tool: call.Name, - Action: action, - Targets: targets, - Outside: outside, - ResultBytes: len(result.Content), - IsError: result.IsError, - OutcomeKnown: result.OutcomeKnown, - Summary: SummarizeTool(call.Name, call.Input, targets, outside, result.IsError), + Seq: len(trace.Events), + Timestamp: call.Timestamp, + Tool: call.Name, + Action: action, + Targets: targets, + Outside: outside, + ResultBytes: len(result.Content), + IsError: result.IsError, + OutcomeKnown: result.OutcomeKnown, + Summary: SummarizeTool(call.Name, call.Input, targets, outside, result.IsError), + ProviderExecuted: call.ProviderExecuted, } } @@ -226,14 +418,17 @@ func ContentToString(v any) string { if text, ok := m["text"].(string); ok { parts = append(parts, text) } + if text, ok := m["content"].(string); ok { parts = append(parts, text) } } } + return strings.Join(parts, "\n") default: b, _ := json.Marshal(v) + return string(b) } } @@ -253,82 +448,118 @@ func actionFor(tool string, input map[string]any, result string) string { switch tool { case "Read", "read": return "read" - case "Write", "Edit", "MultiEdit", "NotebookEdit", "apply_patch", "write", "edit": + case "Write", "Edit", "MultiEdit", "NotebookEdit", "apply_patch", "write", "edit", "multiedit": return "edit" - case "Grep", "Glob", "LS", "view_image", "grep", "find", "ls": + case "Grep", "Glob", "LS", "view_image", "grep", "find", "ls", "glob", "sourcegraph": return "search" case "Bash", "bash", "exec_command", "write_stdin", "js", "js_repl": command := firstString(input, "command", "cmd", "code", "chars", "script", "_raw") if verifyCommand(command) { return "verify" } + if searchCommand(command) { return "search" } + if readCommand(command) { return "read" } + return "exec" case "exec": if len(execPatchPaths(input)) > 0 { return "edit" } + commands := execCommands(input) if len(commands) == 0 || !execHasOnlyStaticCommands(input, len(commands)) { return "exec" } + allVerify, allSearch, allRead := true, true, true + for _, command := range commands { if !verifyCommand(command.command) { allVerify = false } + if !searchCommand(command.command) { allSearch = false } + if !readCommand(command.command) { allRead = false } } + if allVerify { return "verify" } + if allSearch { return "search" } + if allRead { return "read" } + return "exec" + case "view", + "lsp_definition", + "lsp_references", + "lsp_symbols", + "lsp_call_hierarchy", + "fetch", + "agentic_fetch", + "web_fetch": + return "read" + case "download": + return "read" + case "todos", "question": + return "other" default: _ = result + return "other" } } func targetsFor(cwd, tool string, input map[string]any, result string) ([]model.Target, []model.OutsideTouch) { - var targets []model.Target - var outside []model.OutsideTouch + var ( + targets []model.Target + outside []model.OutsideTouch + ) + add := func(path, touch string, weak bool, lines [][2]int, base string) { rel, out, ok := normalizePath(cwd, base, path) if !ok { return } + if out != nil { outside = append(outside, *out) + return } + if weak && !repoPathExists(cwd, rel) { return } + for i := range targets { if targets[i].Path == rel { if model.RankTouch(touch) > model.RankTouch(targets[i].Touch) { targets[i].Touch = touch } + targets[i].Lines = append(targets[i].Lines, lines...) + return } } + targets = append(targets, model.Target{Path: rel, Touch: touch, Lines: lines, Weak: weak}) } @@ -337,16 +568,19 @@ func targetsFor(cwd, tool string, input map[string]any, result string) ([]model. if path, ok := input["file_path"].(string); ok { add(path, "read", false, readLines(input), "") } + if path, ok := input["path"].(string); ok { add(path, "read", false, readLines(input), "") } - case "Write", "Edit", "MultiEdit", "NotebookEdit", "write", "edit": + case "Write", "Edit", "MultiEdit", "NotebookEdit", "write", "edit", "multiedit": if path, ok := input["file_path"].(string); ok { add(path, "edit", false, nil, "") } + if path, ok := input["notebook_path"].(string); ok { add(path, "edit", false, nil, "") } + if path, ok := input["path"].(string); ok { add(path, "edit", false, nil, "") } @@ -354,52 +588,72 @@ func targetsFor(cwd, tool string, input map[string]any, result string) ([]model. for _, hit := range parsePathHits(result) { add(hit.path, "hit", false, hit.lines, "") } + if len(targets) == 0 { if path, ok := input["path"].(string); ok { add(path, "hit", true, nil, "") } } - case "Glob", "LS", "find", "ls": + case "Glob", "LS", "find", "ls", "glob": for _, hit := range parsePathHits(result) { add(hit.path, "hit", false, nil, "") } + if path, ok := input["path"].(string); ok && len(targets) == 0 { add(path, "hit", true, nil, "") } case "Bash", "bash": - command := firstString(input, "command") + command := firstString(input, "command", "cmd", "_raw") + + base := firstString(input, "workdir", "cwd") for _, path := range commandReadPaths(command) { - add(path, "read", true, nil, "") + add(path, "read", true, nil, base) } + for _, path := range extractCommandPaths(command) { - add(path, "hit", true, nil, "") + add(path, "hit", true, nil, base) } + for _, path := range extractPaths(command + "\n" + result) { - add(path, "hit", true, nil, "") + add(path, "hit", true, nil, base) + } + + for _, t := range gitDiffTargets(result) { + add(t.path, "read", true, t.lines, base) } case "exec_command": command := firstString(input, "cmd", "command") + base := firstString(input, "workdir") for _, path := range commandReadPaths(command) { add(path, "read", true, nil, base) } + for _, path := range extractCommandPaths(command) { add(path, "hit", true, nil, base) } + for _, path := range extractPaths(command + "\n" + result) { add(path, "hit", true, nil, base) } + for _, hit := range parsePathHits(result) { add(hit.path, "hit", true, hit.lines, base) } + + for _, t := range gitDiffTargets(result) { + add(t.path, "read", true, t.lines, base) + } case "exec": for _, command := range execCommands(input) { for _, path := range commandReadPaths(command.command) { add(path, "read", true, nil, command.workdir) } + for _, path := range extractCommandPaths(command.command) { add(path, "hit", true, nil, command.workdir) } + for _, path := range extractPaths(command.command) { add(path, "hit", true, nil, command.workdir) } @@ -410,9 +664,15 @@ func targetsFor(cwd, tool string, input map[string]any, result string) ([]model. for _, path := range extractPaths(result) { add(path, "hit", true, nil, "") } + for _, hit := range parsePathHits(result) { add(hit.path, "hit", true, hit.lines, "") } + + for _, t := range gitDiffTargets(result) { + add(t.path, "read", true, t.lines, "") + } + for _, path := range execPatchPaths(input) { add(path, "edit", false, nil, "") } @@ -430,7 +690,32 @@ func targetsFor(cwd, tool string, input map[string]any, result string) ([]model. for _, path := range extractPaths(code + "\n" + result) { add(path, "hit", true, nil, "") } + case "view": + // Crush's view tool uses {file_path, start_line?, end_line?}; + // the JSON literal stored in parts[].data.input occasionally + // nests an extra wrapper, but normalizePath tolerates any + // plain string we surface here. + if path, ok := input["file_path"].(string); ok { + add(path, "read", false, readLines(input), "") + } + + if path, ok := input["path"].(string); ok { + add(path, "read", false, nil, "") + } + case "lsp_definition", "lsp_references", "lsp_symbols", "lsp_call_hierarchy": + // LSP lookup results carry the target path in the result body; + // mine it the same way parsePathHits does for Grep. + for _, hit := range parsePathHits(result) { + add(hit.path, "hit", false, hit.lines, "") + } + case "fetch", "agentic_fetch", "web_fetch": + // Fetch tools return URL-only payloads — there is no path to + // target. Leaving targets empty keeps the citymap free of + // spurious hits while the action still surfaces as "read". + case "download": + // Download targets a remote URL too; nothing to anchor. } + return targets, outside } @@ -440,6 +725,7 @@ func firstString(input map[string]any, keys ...string) string { return value } } + return "" } @@ -451,8 +737,13 @@ type execCommand struct { // execStringFieldRe recognizes only JSON-compatible double-quoted string // literals assigned to cmd or workdir. It intentionally does not attempt to // parse arbitrary JavaScript expressions. -var execStringFieldRe = regexp.MustCompile(`(?:^|[[:space:],{])(?:"(cmd|workdir)"|(cmd|workdir))\s*:\s*("(?:\\.|[^"\\])*")`) -var execPatchAssignmentRe = regexp.MustCompile(`(?m)^[\t ]*(?:const|let|var)[\t ]+patch[\t ]*=[\t ]*("(?:\\.|[^"\\])*")[\t ]*;`) +var execStringFieldRe = regexp.MustCompile( + `(?:^|[[:space:],{])(?:"(cmd|workdir)"|(cmd|workdir))\s*:\s*("(?:\\.|[^"\\])*")`, +) + +var execPatchAssignmentRe = regexp.MustCompile( + `(?m)^[\t ]*(?:const|let|var)[\t ]+patch[\t ]*=[\t ]*("(?:\\.|[^"\\])*")[\t ]*;`, +) func execSource(input map[string]any) string { for _, key := range []string{"_raw", "code", "script"} { @@ -460,6 +751,7 @@ func execSource(input map[string]any) string { return candidate } } + return "" } @@ -468,15 +760,18 @@ func execHasOnlyStaticCommands(input map[string]any, commandCount int) bool { if source == "" { return firstString(input, "cmd", "command") != "" } + tools := execToolNames(source) if len(tools) != commandCount { return false } + for _, tool := range tools { if tool != "exec_command" { return false } } + return true } @@ -486,16 +781,19 @@ func execCommands(input map[string]any) []execCommand { if command := firstString(input, "cmd", "command"); command != "" { return []execCommand{{command: command, workdir: firstString(input, "workdir")}} } + return nil } arguments := execCommandArguments(source) + commands := make([]execCommand, 0, len(arguments)) for _, argument := range arguments { if command, ok := parseStaticExecCommand(argument); ok { commands = append(commands, command) } } + return commands } @@ -504,30 +802,37 @@ func execPatchPaths(input map[string]any) []string { if source == "" { return nil } + match := execPatchAssignmentRe.FindStringSubmatch(source) if len(match) != 2 { return nil } + var patch string if json.Unmarshal([]byte(match[1]), &patch) != nil { return nil } + for _, argument := range execToolArguments(source, "apply_patch") { if strings.TrimSpace(argument) == "patch" { return parsePatchPaths(patch) } } + return nil } func parseStaticExecCommand(argument string) (execCommand, bool) { var command execCommand + ambiguousWorkdir := false + for _, match := range execStringFieldRe.FindAllStringSubmatchIndex(argument, -1) { keyStart, keyEnd := match[2], match[3] if keyStart < 0 { keyStart, keyEnd = match[4], match[5] } + key := argument[keyStart:keyEnd] var value string @@ -539,19 +844,24 @@ func parseStaticExecCommand(argument string) (execCommand, bool) { if command.command != "" { return execCommand{}, false } + command.command = value + continue } if command.workdir != "" { ambiguousWorkdir = true command.workdir = "" + continue } + if !ambiguousWorkdir { command.workdir = value } } + return command, command.command != "" } @@ -561,72 +871,98 @@ func execCommandArguments(source string) []string { func execToolArguments(source, tool string) []string { call := "tools." + tool + var arguments []string + for i := 0; i < len(source); { if next, ok := skipJSIgnored(source, i); ok { i = next + continue } + if !strings.HasPrefix(source[i:], call) || (i > 0 && isJSIdentifierByte(source[i-1])) { i++ + continue } + open := i + len(call) for open < len(source) && isJSSpace(source[open]) { open++ } + if open >= len(source) || source[open] != '(' { i++ + continue } + close, ok := matchingJSParen(source, open) if !ok { break } + arguments = append(arguments, source[open+1:close]) i = close + 1 } + return arguments } func execToolNames(source string) []string { const prefix = "tools." + var names []string + for i := 0; i < len(source); { if next, ok := skipJSIgnored(source, i); ok { i = next + continue } + if !strings.HasPrefix(source[i:], prefix) || (i > 0 && isJSIdentifierByte(source[i-1])) { i++ + continue } + nameStart := i + len(prefix) + nameEnd := nameStart for nameEnd < len(source) && isJSIdentifierByte(source[nameEnd]) { nameEnd++ } + open := nameEnd for open < len(source) && isJSSpace(source[open]) { open++ } + if nameEnd == nameStart || open >= len(source) || source[open] != '(' { i++ + continue } + names = append(names, source[nameStart:nameEnd]) i = open + 1 } + return names } func matchingJSParen(source string, open int) (int, bool) { depth := 1 + for i := open + 1; i < len(source); { if next, ok := skipJSIgnored(source, i); ok { i = next + continue } + switch source[i] { case '(': depth++ @@ -636,8 +972,10 @@ func matchingJSParen(source string, open int) (int, bool) { return i, true } } + i++ } + return 0, false } @@ -645,31 +983,39 @@ func skipJSIgnored(source string, start int) (int, bool) { if start >= len(source) { return start, false } + if quote := source[start]; quote == '\'' || quote == '"' || quote == '`' { for i := start + 1; i < len(source); i++ { if source[i] == '\\' { i++ + continue } + if source[i] == quote { return i + 1, true } } + return len(source), true } + if source[start] != '/' || start+1 >= len(source) { return start, false } + switch source[start+1] { case '/': if end := strings.IndexByte(source[start+2:], '\n'); end >= 0 { return start + 2 + end + 1, true } + return len(source), true case '*': if end := strings.Index(source[start+2:], "*/"); end >= 0 { return start + 2 + end + 2, true } + return len(source), true default: return start, false @@ -688,20 +1034,25 @@ func repoPathExists(cwd, rel string) bool { if cwd == "" || rel == "" { return false } + abs := filepath.Join(cwd, filepath.FromSlash(rel)) _, err := os.Stat(abs) + return err == nil } func readLines(input map[string]any) [][2]int { offset := IntFromAny(input["offset"]) limit := IntFromAny(input["limit"]) + if offset <= 0 { return nil } + if limit <= 0 { return [][2]int{{offset, offset}} } + return [][2]int{{offset, offset + limit - 1}} } @@ -710,14 +1061,17 @@ func normalizePath(cwd, base, path string) (string, *model.OutsideTouch, bool) { if path == "" || strings.Contains(path, "\n") { return "", nil, false } + if strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") { return "", nil, false } + if !filepath.IsAbs(path) { clean := filepath.Clean(path) if clean == "." || strings.HasPrefix(clean, "..") { return "", nil, false } + if base != "" && filepath.IsAbs(base) { abs := filepath.Clean(filepath.Join(base, clean)) if cwd != "" { @@ -726,10 +1080,13 @@ func normalizePath(cwd, base, path string) (string, *model.OutsideTouch, bool) { return filepath.ToSlash(rel), nil, true } } + return "", &model.OutsideTouch{Scope: outsideScope(abs), Path: abs}, true } + return filepath.ToSlash(clean), nil, true } + abs := filepath.Clean(path) if cwd != "" { root := filepath.Clean(cwd) @@ -737,6 +1094,7 @@ func normalizePath(cwd, base, path string) (string, *model.OutsideTouch, bool) { return filepath.ToSlash(rel), nil, true } } + return "", &model.OutsideTouch{Scope: outsideScope(abs), Path: abs}, true } @@ -747,9 +1105,11 @@ func outsideScope(path string) string { return "home" } } + if strings.HasPrefix(path, os.TempDir()) || strings.HasPrefix(path, "/tmp") { return "tmp" } + return "other" } @@ -758,104 +1118,253 @@ type pathHit struct { lines [][2]int } -var pathLineRe = regexp.MustCompile(`(?:^|[\s"'([])([A-Za-z0-9_./@+-]*[A-Za-z0-9_/@+-]\.[A-Za-z0-9][A-Za-z0-9._-]*):([0-9]+)`) -var pathOnlyRe = regexp.MustCompile(`(?:^|[\s"'([])([./~A-Za-z0-9_@+-]*[/][A-Za-z0-9_./~@+-]*\.[A-Za-z0-9][A-Za-z0-9._-]*)(?:$|[\s"',)\]:;])`) -var commandPathRe = regexp.MustCompile(`(?:^|[\s"'=])([./~A-Za-z0-9_@+-]+\.[A-Za-z0-9][A-Za-z0-9._-]*)(?:$|[\s"',)\]:;])`) -var patchFileRe = regexp.MustCompile(`(?m)^\*\*\* (?:Add|Update|Delete) File: (.+)$|^\*\*\* Move to: (.+)$`) +var pathLineRe = regexp.MustCompile( + `(?:^|[\s"'([])([A-Za-z0-9_./@+-]*[A-Za-z0-9_/@+-]\.[A-Za-z0-9][A-Za-z0-9._-]*):([0-9]+)`, +) + +var pathOnlyRe = regexp.MustCompile( + `(?:^|[\s"'([])([./~A-Za-z0-9_@+-]*[/][A-Za-z0-9_./~@+-]*\.[A-Za-z0-9][A-Za-z0-9._-]*)(?:$|[\s"',)\]:;])`, +) + +var commandPathRe = regexp.MustCompile( + `(?:^|[\s"'=])([./~A-Za-z0-9_@+-]+\.[A-Za-z0-9][A-Za-z0-9._-]*)(?:$|[\s"',)\]:;])`, +) + +var ( + patchFileRe = regexp.MustCompile(`(?m)^\*\*\* (?:Add|Update|Delete) File: (.+)$|^\*\*\* Move to: (.+)$`) + gitDiffHeaderRe = regexp.MustCompile(`(?m)^diff --git a/.+? b/(.+)$`) + gitDiffHeaderQuotedRe = regexp.MustCompile(`(?m)^diff --git "a/.+?" "b/(.+?)"$`) + gitDiffPlusRe = regexp.MustCompile(`(?m)^\+\+\+ b/(.+)$`) + gitDiffPlusQuotedRe = regexp.MustCompile(`(?m)^\+\+\+ "b/(.+?)"$`) + gitDiffHunkRe = regexp.MustCompile(`(?m)^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@`) +) func parsePathHits(text string) []pathHit { byPath := map[string][][2]int{} + for _, m := range pathLineRe.FindAllStringSubmatch(text, -1) { line := 0 - fmt.Sscanf(m[2], "%d", &line) + + _, _ = fmt.Sscanf(m[2], "%d", &line) if line > 0 { if path, ok := cleanExtractedPath(m[1], true); ok { byPath[path] = append(byPath[path], [2]int{line, line}) } } } + for _, p := range extractPaths(text) { if _, ok := byPath[p]; !ok { byPath[p] = nil } } + out := make([]pathHit, 0, len(byPath)) for path, lines := range byPath { out = append(out, pathHit{path: path, lines: lines}) } + sort.Slice(out, func(i, j int) bool { return out[i].path < out[j].path }) + return out } func extractPaths(text string) []string { matches := pathOnlyRe.FindAllStringSubmatch(text, -1) seen := map[string]bool{} + paths := make([]string, 0, len(matches)) for _, m := range matches { path, ok := cleanExtractedPath(m[1], false) if !ok { continue } + if path == "" || seen[path] || strings.Contains(path, "://") { continue } + seen[path] = true paths = append(paths, path) } + sort.Strings(paths) + return paths } func extractCommandPaths(command string) []string { matches := commandPathRe.FindAllStringSubmatch(command, -1) seen := map[string]bool{} + paths := make([]string, 0, len(matches)) for _, m := range matches { path, ok := cleanExtractedPath(m[1], true) if !ok || seen[path] { continue } + seen[path] = true paths = append(paths, path) } + sort.Strings(paths) + return paths } func parsePatchPaths(patch string) []string { matches := patchFileRe.FindAllStringSubmatch(patch, -1) seen := map[string]bool{} + paths := make([]string, 0, len(matches)) for _, m := range matches { raw := m[1] if raw == "" { raw = m[2] } + path, ok := cleanExtractedPath(raw, true) if !ok || seen[path] { continue } + seen[path] = true paths = append(paths, path) } + sort.Strings(paths) + return paths } +// diffTarget carries a diff-extracted path and the hunk line ranges +// associated with it, so callers can populate model.Target.Lines. +type diffTarget struct { + path string + lines [][2]int +} + +// gitDiffTargets extracts current ("b/") paths from unified diff output and +// the hunk line ranges under each file. It handles three header forms: +// +// - `diff --git a/old b/new` — the standard git header (primary). +// - `diff --git "a/old name" "b/new name"` — git quotes paths with spaces. +// - `+++ b/new` — a fallback for headerless diffs (e.g. `diff -u` output). +// +// Hunk headers (`@@ -o,n +s,n @@`) are associated with the current file and +// turned into [start, start+count-1] line ranges. Structural parsing is more +// reliable than the generic extractPaths regex, which leaves diff paths as +// weak "hit" targets indistinguishable from unvisited files in the citymap. +func gitDiffTargets(text string) []diffTarget { + seen := map[string]int{} // path → index into result + + var ( + result []diffTarget + currentPath string + ) + // currentHasDiffGit tracks whether the current file section was + // introduced by a diff --git header. When true, the +++ fallback + // for the same section is suppressed. When a new section starts + // (via --- or a new diff --git), the flag resets. This prevents + // a single diff --git from suppressing +++ headers for unrelated + // headerless files later in the same output. + currentHasDiffGit := false + + ensure := func(path string) { + path = strings.TrimSpace(path) + if path == "" || path == "/dev/null" { + return + } + + if _, ok := seen[path]; ok { + return + } + + seen[path] = len(result) + result = append(result, diffTarget{path: path}) + } + + for raw := range strings.SplitSeq(text, "\n") { + if m := gitDiffHeaderRe.FindStringSubmatch(raw); m != nil { + currentHasDiffGit = true + currentPath = strings.TrimSpace(m[1]) + ensure(currentPath) + + continue + } + + if m := gitDiffHeaderQuotedRe.FindStringSubmatch(raw); m != nil { + currentHasDiffGit = true + currentPath = strings.TrimSpace(m[1]) + ensure(currentPath) + + continue + } + // A --- line starts a new file section in headerless diffs. + // Reset the per-file flag so the following +++ is honoured. + if strings.HasPrefix(raw, "--- ") { + currentHasDiffGit = false + + continue + } + + if m := gitDiffPlusRe.FindStringSubmatch(raw); m != nil { + if !currentHasDiffGit { + currentPath = strings.TrimSpace(m[1]) + ensure(currentPath) + } + + continue + } + + if m := gitDiffPlusQuotedRe.FindStringSubmatch(raw); m != nil { + if !currentHasDiffGit { + currentPath = strings.TrimSpace(m[1]) + ensure(currentPath) + } + + continue + } + + if m := gitDiffHunkRe.FindStringSubmatch(raw); m != nil && currentPath != "" { + start, _ := strconv.Atoi(m[1]) + + count := 1 + if m[2] != "" { + count, _ = strconv.Atoi(m[2]) + } + + if count > 0 { + idx := seen[currentPath] + result[idx].lines = append(result[idx].lines, [2]int{start, start + count - 1}) + } + } + } + + sort.Slice(result, func(i, j int) bool { return result[i].path < result[j].path }) + + return result +} + func cleanExtractedPath(path string, allowTopLevel bool) (string, bool) { path = strings.TrimSpace(strings.Trim(path, `"' ,;:()[]{}`)) path = strings.TrimPrefix(path, "a/") path = strings.TrimPrefix(path, "b/") + path = strings.TrimPrefix(path, "./") if path == "" || strings.Contains(path, "://") || strings.ContainsAny(path, "\n\r\t") { return "", false } + if strings.HasPrefix(path, "--") || strings.HasPrefix(path, "++") { return "", false } + if !allowTopLevel && !strings.Contains(path, "/") { return "", false } + return path, true } @@ -878,8 +1387,10 @@ var readOnlyPrograms = map[string]bool{ // at least one segment actually searches or lists. Conservative by design — // anything unrecognized stays "exec". func searchCommand(command string) bool { - cleaned := strings.NewReplacer("2>&1", " ", "2>/dev/null", " ", ">/dev/null", " ", "> /dev/null", " ").Replace(command) + cleaned := strings.NewReplacer("2>&1", " ", "2>/dev/null", " ", ">/dev/null", " ", "> /dev/null", " "). + Replace(command) searched := false + segments := strings.FieldsFunc(cleaned, func(r rune) bool { return r == '|' || r == ';' || r == '&' || r == '\n' }) @@ -887,30 +1398,39 @@ func searchCommand(command string) bool { if strings.ContainsRune(segment, '>') { return false } + fields := strings.Fields(segment) for len(fields) > 0 && envAssignRe.MatchString(fields[0]) { fields = fields[1:] } + if len(fields) == 0 { continue } + program := strings.ToLower(filepath.Base(fields[0])) if program == "git" && len(fields) > 1 && (fields[1] == "grep" || fields[1] == "ls-files") { searched = true + continue } + if searchPrograms[program] { // find can mutate through -exec/-delete; keep those out of "search" if strings.Contains(segment, "-exec") || strings.Contains(segment, "-delete") { return false } + searched = true + continue } + if !readOnlyPrograms[program] { return false } } + return searched } @@ -926,7 +1446,10 @@ func readCommand(command string) bool { if len(commandReadPaths(command)) == 0 { return false } - cleaned := strings.NewReplacer("2>&1", " ", "2>/dev/null", " ", ">/dev/null", " ", "> /dev/null", " ").Replace(command) + + cleaned := strings.NewReplacer("2>&1", " ", "2>/dev/null", " ", ">/dev/null", " ", "> /dev/null", " "). + Replace(command) + segments := strings.FieldsFunc(cleaned, func(r rune) bool { return r == '|' || r == ';' || r == '&' || r == '\n' }) @@ -934,21 +1457,26 @@ func readCommand(command string) bool { if strings.ContainsRune(segment, '>') { return false } + fields := strings.Fields(segment) for len(fields) > 0 && envAssignRe.MatchString(fields[0]) { fields = fields[1:] } + if len(fields) == 0 { continue } + program := strings.ToLower(filepath.Base(fields[0])) if program == "sed" && !sedReadsOnly(fields[1:]) { return false } + if !readOnlyPrograms[program] && !readPrograms[program] { return false } } + return true } @@ -957,7 +1485,9 @@ func readCommand(command string) bool { // of opening a file to read it. func commandReadPaths(command string) []string { seen := map[string]bool{} + var paths []string + segments := strings.FieldsFunc(command, func(r rune) bool { return r == '|' || r == ';' || r == '&' || r == '\n' }) @@ -965,66 +1495,85 @@ func commandReadPaths(command string) []string { if strings.ContainsRune(segment, '>') { continue } + fields := strings.Fields(segment) for len(fields) > 0 && envAssignRe.MatchString(fields[0]) { fields = fields[1:] } + if len(fields) == 0 { continue } + program := strings.ToLower(filepath.Base(fields[0])) if !readPrograms[program] { continue } + args := fields[1:] scriptArgs := 0 + if program == "sed" { // only the read idiom: sed -n '…p' file — anything else can rewrite if !sedReadsOnly(args) { continue } + scriptArgs = 1 } + expectValue := false for _, arg := range args { if expectValue { expectValue = false + continue } + if strings.HasPrefix(arg, "-") { expectValue = flagTakesValue(program, arg) + continue } + if scriptArgs > 0 { scriptArgs-- + continue } // globs, redirections, and substitutions are not literal file paths if strings.ContainsAny(arg, "<>*?$`") { continue } + path, ok := cleanExtractedPath(arg, true) if !ok || seen[path] { continue } + seen[path] = true paths = append(paths, path) } } + sort.Strings(paths) + return paths } func sedReadsOnly(args []string) bool { hasN := false + for _, arg := range args { if arg == "-n" { hasN = true } + if strings.HasPrefix(arg, "-i") { return false } } + return hasN } @@ -1035,17 +1584,31 @@ func flagTakesValue(program, flag string) bool { case "sed": return flag == "-e" || flag == "-f" } + return false } func verifyCommand(command string) bool { c := strings.ToLower(command) - patterns := []string{"go test", "go vet", "npm test", "npm run build", "pnpm test", "pnpm build", "pytest", "make test", "cargo test", "swift test"} + + patterns := []string{ + "go test", + "go vet", + "npm test", + "npm run build", + "pnpm test", + "pnpm build", + "pytest", + "make test", + "cargo test", + "swift test", + } for _, pattern := range patterns { if strings.Contains(c, pattern) { return true } } + return false } @@ -1057,37 +1620,50 @@ const toolSummaryVerbLimit = 96 func summarizeExecWrapper(input map[string]any) string { commands := execCommands(input) source := execSource(input) + nestedTools := execToolNames(source) if len(commands) == 0 && len(nestedTools) == 0 { return "" } - primary := "" + var primary string if len(commands) > 0 { primary = commands[0].command } else { primary = nestedTools[0] } + additionalCalls := len(commands) - 1 if len(nestedTools) > 0 { additionalCalls = len(nestedTools) - 1 } suffix := "" - if additionalCalls == 1 { - suffix = " (+1 more tool call)" - } else if additionalCalls > 1 { - suffix = fmt.Sprintf(" (+%d more tool calls)", additionalCalls) + if additionalCalls > 0 { + suffix = fmt.Sprintf( + " (+%d more tool %s)", + additionalCalls, + english.PluralWord(additionalCalls, "call", "calls"), + ) } + commandLimit := toolSummaryVerbLimit - len([]rune(suffix)) + return textutil.TruncateRunes(primary, commandLimit, "...") + suffix } -func SummarizeTool(tool string, input map[string]any, targets []model.Target, outside []model.OutsideTouch, isError bool) string { +func SummarizeTool( + tool string, + input map[string]any, + targets []model.Target, + outside []model.OutsideTouch, + isError bool, +) string { verb := tool if desc, ok := input["description"].(string); ok && desc != "" { verb = desc } + if command := firstString(input, "command", "cmd"); command != "" { verb = textutil.TruncateRunes(command, toolSummaryVerbLimit, "...") } else if tool == "exec" { @@ -1095,9 +1671,11 @@ func SummarizeTool(tool string, input map[string]any, targets []model.Target, ou verb = summary } } + status := "" if isError { status = " error" } + return fmt.Sprintf("%s -> %d targets, %d outside%s", verb, len(targets), len(outside), status) } diff --git a/internal/adapter/adapter_test.go b/internal/adapter/adapter_test.go index 9d9d0ab..4ced6fd 100644 --- a/internal/adapter/adapter_test.go +++ b/internal/adapter/adapter_test.go @@ -17,12 +17,15 @@ func TestAgentNodeIDIsStableAndRootScoped(t *testing.T) { if got != AgentNodeID("codex", "root-key", "codex-agent:child") { t.Fatal("unstable") } + if !regexp.MustCompile(`^agt_[0-9a-f]{24}$`).MatchString(got) { t.Fatalf("id=%q", got) } + if got == AgentNodeID("codex", "other-root", "codex-agent:child") { t.Fatal("not root scoped") } + if got == AgentNodeID("claude-code", "root-key", "codex-agent:child") { t.Fatal("not harness scoped") } @@ -42,6 +45,7 @@ func TestAgentInstructionPreviewNormalizesAndTruncatesRunes(t *testing.T) { if len([]rune(got)) != 240 { t.Fatalf("truncated preview is %d runes, want 240", len([]rune(got))) } + if !strings.HasSuffix(got, "…") { t.Fatalf("truncated preview missing ellipsis: %q", got) } @@ -49,13 +53,16 @@ func TestAgentInstructionPreviewNormalizesAndTruncatesRunes(t *testing.T) { func TestSessionKeyIsStableAndSourceSpecific(t *testing.T) { path := filepath.Join(t.TempDir(), "session.jsonl") + first := SessionKey("codex", path) if second := SessionKey("codex", path); second != first { t.Fatalf("SessionKey changed: %q != %q", first, second) } + if other := SessionKey("claude-code", path); other == first { t.Fatalf("SessionKey ignored harness: %q", other) } + if other := SessionKey("codex", path+".copy"); other == first { t.Fatalf("SessionKey ignored path: %q", other) } @@ -63,13 +70,16 @@ func TestSessionKeyIsStableAndSourceSpecific(t *testing.T) { func TestUserMessageNoteStaysWithinRuneBudget(t *testing.T) { long := strings.Repeat("字", userMessageNoteLimit+50) + note := UserMessageNote(long) if got := len([]rune(note)); got != userMessageNoteLimit { t.Fatalf("truncated note is %d runes, want %d (ellipsis must fit the budget)", got, userMessageNoteLimit) } + if !strings.HasSuffix(note, "…") { t.Fatalf("truncated note missing ellipsis marker: %q", note[len(note)-12:]) } + exact := strings.Repeat("a", userMessageNoteLimit) if UserMessageNote(exact) != exact { t.Fatal("text at the limit must pass through untouched") @@ -83,6 +93,7 @@ func TestSummarizeToolTruncatesCommandAtRuneBoundary(t *testing.T) { if !utf8.ValidString(summary) { t.Fatalf("summary contains invalid UTF-8: %q", summary) } + want := strings.Repeat("a", 92) + "界... -> 0 targets, 0 outside" if summary != want { t.Fatalf("summary = %q, want %q", summary, want) @@ -168,13 +179,18 @@ func TestInjectedUserMessageShape(t *testing.T) { func TestBuildEventKeepsExecAggregatedAndFindsSingleCommandTarget(t *testing.T) { root := t.TempDir() writeAdapterTestFile(t, root, "README.md") - source := `const r = await tools.exec_command({cmd:"sed -n '1,20p' README.md",workdir:` + jsonString(t, root) + `});` + source := `const r = await tools.exec_command({cmd:"sed -n '1,20p' README.md",workdir:` + jsonString( + t, + root, + ) + `});` event := buildExecEvent(root, map[string]any{"_raw": source}) if event.Tool != "exec" || event.Action != "read" { t.Fatalf("event = %#v", event) } - if len(event.Targets) != 1 || event.Targets[0].Path != "README.md" || !event.Targets[0].Weak || event.Targets[0].Touch != "read" { + + if len(event.Targets) != 1 || event.Targets[0].Path != "README.md" || !event.Targets[0].Weak || + event.Targets[0].Touch != "read" { t.Fatalf("targets = %#v", event.Targets) } } @@ -182,6 +198,7 @@ func TestBuildEventKeepsExecAggregatedAndFindsSingleCommandTarget(t *testing.T) func TestBuildEventExtractsApplyPatchFromExec(t *testing.T) { root := t.TempDir() writeAdapterTestFile(t, root, "src/main.go") + patch := "*** Begin Patch\n*** Update File: src/main.go\n@@\n-old\n+new\n*** End Patch" source := `const patch = ` + jsonString(t, patch) + `; text(await tools.apply_patch(patch));` @@ -189,7 +206,9 @@ func TestBuildEventExtractsApplyPatchFromExec(t *testing.T) { if event.Tool != "exec" || event.Action != "edit" { t.Fatalf("event = %#v", event) } - if len(event.Targets) != 1 || event.Targets[0].Path != "src/main.go" || event.Targets[0].Touch != "edit" || event.Targets[0].Weak { + + if len(event.Targets) != 1 || event.Targets[0].Path != "src/main.go" || event.Targets[0].Touch != "edit" || + event.Targets[0].Weak { t.Fatalf("targets = %#v", event.Targets) } } @@ -209,9 +228,11 @@ func TestBuildEventExtractsPromiseAllCommandTargets(t *testing.T) { if event.Tool != "exec" || event.Action != "exec" { t.Fatalf("event = %#v", event) } + if len(event.Targets) != 2 { t.Fatalf("targets = %#v", event.Targets) } + want := []struct { path string touch string @@ -227,6 +248,7 @@ func TestBuildEventDecodesEscapedExecStrings(t *testing.T) { root := t.TempDir() workdir := filepath.Join(root, `quoted"dir`) writeAdapterTestFile(t, workdir, "src/main.go") + command := `sed -n "1,20p" src/main.go` source := `tools.exec_command({cmd:` + jsonString(t, command) + `,workdir:` + jsonString(t, workdir) + `});` @@ -315,7 +337,12 @@ func TestBuildEventClassifiesBashSearchCommands(t *testing.T) { for _, tt := range tests { t.Run(tt.command, func(t *testing.T) { trace := &model.Trace{Session: model.TraceSession{Cwd: t.TempDir()}} - event := BuildEvent(trace, ToolCall{Name: "Bash", Input: map[string]any{"command": tt.command}}, ToolResult{}) + + event := BuildEvent( + trace, + ToolCall{Name: "Bash", Input: map[string]any{"command": tt.command}}, + ToolResult{}, + ) if event.Action != tt.want { t.Fatalf("action(%q) = %q, want %q", tt.command, event.Action, tt.want) } @@ -340,6 +367,7 @@ func TestBuildEventPreservesOutcomeCertainty(t *testing.T) { func TestBuildEventExecActionAllSearchCommands(t *testing.T) { source := `Promise.all([tools.exec_command({cmd:"rg TODO main.go"}), tools.exec_command({cmd:"ls src"})])` + event := buildExecEvent(t.TempDir(), map[string]any{"_raw": source}) if event.Tool != "exec" || event.Action != "search" { t.Fatalf("event = %#v, want action %q", event, "search") @@ -348,6 +376,7 @@ func TestBuildEventExecActionAllSearchCommands(t *testing.T) { func TestBuildEventExecActionAllReadCommands(t *testing.T) { source := `Promise.all([tools.exec_command({cmd:"sed -n '1,20p' main.go"}), tools.exec_command({cmd:"cat README.md"})])` + event := buildExecEvent(t.TempDir(), map[string]any{"_raw": source}) if event.Tool != "exec" || event.Action != "read" { t.Fatalf("event = %#v, want action %q", event, "read") @@ -377,6 +406,7 @@ func TestCommandReadPaths(t *testing.T) { if len(got) != len(tt.want) { t.Fatalf("commandReadPaths(%q) = %#v, want %#v", tt.command, got, tt.want) } + for i := range got { if got[i] != tt.want[i] { t.Fatalf("commandReadPaths(%q) = %#v, want %#v", tt.command, got, tt.want) @@ -386,15 +416,181 @@ func TestCommandReadPaths(t *testing.T) { } } +func diffTargetPaths(ts []diffTarget) []string { + paths := make([]string, len(ts)) + for i, t := range ts { + paths[i] = t.path + } + + return paths +} + +func TestGitDiffTargets(t *testing.T) { + diff := "diff --git a/internal/server/server.go b/internal/server/server.go\n" + + "index abc..def 100644\n" + + "--- a/internal/server/server.go\n" + + "+++ b/internal/server/server.go\n" + + "@@ -1,5 +1,7 @@\n" + + "diff --git a/cmd/mindwalk/main.go b/cmd/mindwalk/main.go\n" + + "index 123..456 100644\n" + + "--- a/cmd/mindwalk/main.go\n" + + "+++ b/cmd/mindwalk/main.go\n" + got := diffTargetPaths(gitDiffTargets(diff)) + + want := []string{"cmd/mindwalk/main.go", "internal/server/server.go"} + if len(got) != len(want) { + t.Fatalf("gitDiffTargets paths = %#v, want %#v", got, want) + } + + for i := range got { + if got[i] != want[i] { + t.Fatalf("gitDiffTargets paths = %#v, want %#v", got, want) + } + } +} + +func TestGitDiffTargetsHandlesRenames(t *testing.T) { + diff := "diff --git a/old/path.go b/new/path.go\n" + + "similarity index 95%\n" + + "rename from old/path.go\n" + + "rename to new/path.go\n" + + got := diffTargetPaths(gitDiffTargets(diff)) + if len(got) != 1 || got[0] != "new/path.go" { + t.Fatalf("gitDiffTargets = %#v, want [new/path.go]", got) + } +} + +func TestGitDiffTargetsEmpty(t *testing.T) { + if got := gitDiffTargets("no diff here"); len(got) != 0 { + t.Fatalf("gitDiffTargets = %#v, want empty", got) + } +} + +func TestGitDiffTargetsQuotedSpaces(t *testing.T) { + diff := "diff --git \"a/foo bar.go\" \"b/foo bar.go\"\n" + + "index abc..def 100644\n" + + "--- a/foo bar.go\n" + + "+++ b/foo bar.go\n" + + "@@ -1,3 +1,4 @@\n" + + "+new line\n" + + got := gitDiffTargets(diff) + if len(got) != 1 { + t.Fatalf("gitDiffTargets = %#v, want 1 target", got) + } + + if got[0].path != "foo bar.go" { + t.Fatalf("path = %q, want %q", got[0].path, "foo bar.go") + } +} + +func TestGitDiffTargetsPlusPlusFallback(t *testing.T) { + diff := "--- a/old/path.go\n" + + "+++ b/new/path.go\n" + + "@@ -1,3 +1,4 @@\n" + + "+added\n" + + got := diffTargetPaths(gitDiffTargets(diff)) + if len(got) != 1 || got[0] != "new/path.go" { + t.Fatalf("gitDiffTargets = %#v, want [new/path.go]", got) + } +} + +func TestGitDiffTargetsMixedHeadersAndFallback(t *testing.T) { + diff := "diff --git a/main.go b/main.go\n" + + "--- a/main.go\n" + + "+++ b/main.go\n" + + "@@ -1,3 +1,4 @@\n" + + "+added\n" + + "--- a/legacy.go\n" + + "+++ b/legacy.go\n" + + "@@ -1,3 +1,4 @@\n" + + "+legacy\n" + got := diffTargetPaths(gitDiffTargets(diff)) + + want := []string{"legacy.go", "main.go"} + if len(got) != 2 { + t.Fatalf( + "gitDiffTargets = %#v, want %#v (headerless file should not be suppressed by earlier diff --git)", + got, + want, + ) + } + + for i, p := range want { + if got[i] != p { + t.Fatalf("got[%d] = %q, want %q", i, got[i], p) + } + } +} + +func TestGitDiffTargetsHunkLineRanges(t *testing.T) { + diff := "diff --git a/main.go b/main.go\n" + + "--- a/main.go\n" + + "+++ b/main.go\n" + + "@@ -1,3 +10,5 @@\n" + + "@@ -20,1 +30,2 @@\n" + + got := gitDiffTargets(diff) + if len(got) != 1 { + t.Fatalf("gitDiffTargets = %#v, want 1 target", got) + } + + want := [][2]int{{10, 14}, {30, 31}} + if len(got[0].lines) != len(want) { + t.Fatalf("lines = %#v, want %#v", got[0].lines, want) + } + + for i := range want { + if got[0].lines[i] != want[i] { + t.Fatalf("lines[%d] = %v, want %v", i, got[0].lines[i], want[i]) + } + } +} + +func TestBuildEventBashGitDiffProducesReadTargets(t *testing.T) { + root := t.TempDir() + writeAdapterTestFile(t, root, "internal/server/server.go") + writeAdapterTestFile(t, root, "cmd/mindwalk/main.go") + + diff := "diff --git a/internal/server/server.go b/internal/server/server.go\n" + + "index abc..def 100644\n" + + "--- a/internal/server/server.go\n" + + "+++ b/internal/server/server.go\n" + + "@@ -1,5 +1,7 @@\n" + + "diff --git a/cmd/mindwalk/main.go b/cmd/mindwalk/main.go\n" + + "index 123..456 100644\n" + + "--- a/cmd/mindwalk/main.go\n" + + "+++ b/cmd/mindwalk/main.go\n" + trace := &model.Trace{Session: model.TraceSession{Cwd: root}} + + event := BuildEvent(trace, ToolCall{ + Name: "Bash", + Input: map[string]any{"command": "git diff"}, + }, ToolResult{Content: diff}) + if len(event.Targets) != 2 { + t.Fatalf("targets = %#v", event.Targets) + } + + for _, target := range event.Targets { + if target.Touch != "read" || !target.Weak { + t.Fatalf("target %#v should be weak read", target) + } + } +} + func TestBuildEventDoesNotPairDistantExecWorkdir(t *testing.T) { root := t.TempDir() writeAdapterTestFile(t, root, "README.md") + source := `tools.exec_command({cmd:"sed README.md"}); const metadata = {workdir:"/tmp/not-the-command-workdir"};` event := buildExecEvent(root, map[string]any{"_raw": source}) if len(event.Targets) != 1 || event.Targets[0].Path != "README.md" || !event.Targets[0].Weak { t.Fatalf("targets = %#v", event.Targets) } + if len(event.Outside) != 0 { t.Fatalf("outside = %#v", event.Outside) } @@ -403,6 +599,7 @@ func TestBuildEventDoesNotPairDistantExecWorkdir(t *testing.T) { func TestBuildEventIgnoresExecExamplesInStringsAndComments(t *testing.T) { root := t.TempDir() writeAdapterTestFile(t, root, "README.md") + source := "const quoted = 'tools.exec_command({cmd:\"go test ./...\"})';\n" + "const template = `tools.exec_command({cmd:\"sed README.md\"})`;\n" + "// tools.exec_command({cmd:\"sed README.md\"})\n" + @@ -413,6 +610,7 @@ func TestBuildEventIgnoresExecExamplesInStringsAndComments(t *testing.T) { if event.Action != "exec" || len(event.Targets) != 0 { t.Fatalf("event = %#v", event) } + if event.Summary != "exec -> 0 targets, 0 outside" { t.Fatalf("summary = %q", event.Summary) } @@ -421,16 +619,20 @@ func TestBuildEventIgnoresExecExamplesInStringsAndComments(t *testing.T) { func TestBuildEventExtractsJSReplTargets(t *testing.T) { root := t.TempDir() writeAdapterTestFile(t, root, "packages/db/src/index.ts") + code := `const db = await import("./packages/db/src/index.ts")` for _, key := range []string{"code", "_raw"} { t.Run(key, func(t *testing.T) { trace := &model.Trace{Session: model.TraceSession{Cwd: root}} + event := BuildEvent(trace, ToolCall{Name: "js_repl", Input: map[string]any{key: code}}, ToolResult{}) if event.Tool != "js_repl" || event.Action != "exec" { t.Fatalf("event = %#v", event) } - if len(event.Targets) != 1 || event.Targets[0].Path != "packages/db/src/index.ts" || !event.Targets[0].Weak { + + if len(event.Targets) != 1 || event.Targets[0].Path != "packages/db/src/index.ts" || + !event.Targets[0].Weak { t.Fatalf("targets = %#v", event.Targets) } }) @@ -439,15 +641,18 @@ func TestBuildEventExtractsJSReplTargets(t *testing.T) { func buildExecEvent(cwd string, input map[string]any) model.Event { trace := &model.Trace{Session: model.TraceSession{Cwd: cwd}} + return BuildEvent(trace, ToolCall{Name: "exec", Input: input}, ToolResult{}) } func writeAdapterTestFile(t *testing.T, root, path string) { t.Helper() + fullPath := filepath.Join(root, filepath.FromSlash(path)) if err := os.MkdirAll(filepath.Dir(fullPath), 0o755); err != nil { t.Fatal(err) } + if err := os.WriteFile(fullPath, []byte("test\n"), 0o644); err != nil { t.Fatal(err) } @@ -455,10 +660,12 @@ func writeAdapterTestFile(t *testing.T, root, path string) { func jsonString(t *testing.T, value string) string { t.Helper() + encoded, err := json.Marshal(value) if err != nil { t.Fatal(err) } + return string(encoded) } @@ -475,8 +682,22 @@ func TestBuildEventClassifiesPiCoreTools(t *testing.T) { touch string weak bool }{ - {"read", map[string]any{"path": abs, "offset": float64(10), "limit": float64(5)}, "read", "src/main.go", "read", false}, - {"edit", map[string]any{"path": abs, "edits": []any{map[string]any{"oldText": "a", "newText": "b"}}}, "edit", "src/main.go", "edit", false}, + { + "read", + map[string]any{"path": abs, "offset": float64(10), "limit": float64(5)}, + "read", + "src/main.go", + "read", + false, + }, + { + "edit", + map[string]any{"path": abs, "edits": []any{map[string]any{"oldText": "a", "newText": "b"}}}, + "edit", + "src/main.go", + "edit", + false, + }, {"write", map[string]any{"path": abs, "content": "x"}, "edit", "src/main.go", "edit", false}, {"grep", map[string]any{"pattern": "TODO", "path": abs}, "search", "src/main.go", "hit", true}, {"find", map[string]any{"pattern": "*.go", "path": abs}, "search", "src/main.go", "hit", true}, @@ -487,16 +708,20 @@ func TestBuildEventClassifiesPiCoreTools(t *testing.T) { for _, tt := range tests { t.Run(tt.tool, func(t *testing.T) { trace := &model.Trace{Session: model.TraceSession{Cwd: root}} + event := BuildEvent(trace, ToolCall{Name: tt.tool, Input: tt.input}, ToolResult{}) if event.Action != tt.action { t.Fatalf("action = %q, want %q", event.Action, tt.action) } + if tt.path == "" { return } + if len(event.Targets) != 1 { t.Fatalf("targets = %#v", event.Targets) } + target := event.Targets[0] if target.Path != tt.path || target.Touch != tt.touch || target.Weak != tt.weak { t.Fatalf("target = %#v", target) @@ -508,11 +733,149 @@ func TestBuildEventClassifiesPiCoreTools(t *testing.T) { func TestBuildEventPiReadRecordsLineRange(t *testing.T) { trace := &model.Trace{Session: model.TraceSession{Cwd: t.TempDir()}} input := map[string]any{"path": "/abs/elsewhere.go", "offset": float64(10), "limit": float64(5)} + event := BuildEvent(trace, ToolCall{Name: "read", Input: input}, ToolResult{}) if event.Action != "read" { t.Fatalf("action = %q", event.Action) } + if len(event.Outside) != 1 { t.Fatalf("outside = %#v", event.Outside) } } + +func TestOpenFileSuccess(t *testing.T) { + path := filepath.Join(t.TempDir(), "test.jsonl") + if err := os.WriteFile(path, []byte("hello\n"), 0o644); err != nil { + t.Fatal(err) + } + + f, close, err := OpenFile(path) + if err != nil { + t.Fatalf("OpenFile error: %v", err) + } + + if f == nil { + t.Fatal("file is nil") + } + + if close == nil { + t.Fatal("close is nil") + } + + buf := make([]byte, 5) + + n, _ := f.Read(buf) + if string(buf[:n]) != "hello" { + t.Fatalf("read = %q, want hello", buf[:n]) + } + + close() +} + +func TestOpenFileNotFound(t *testing.T) { + f, close, err := OpenFile(filepath.Join(t.TempDir(), "missing.jsonl")) + if err == nil { + t.Fatal("expected error for missing file") + } + + if f != nil { + t.Fatal("file should be nil on error") + } + + if close != nil { + t.Fatal("close should be nil on error") + } +} + +func TestFilesystemDiagnostics(t *testing.T) { + t.Run("empty dir returns error check", func(t *testing.T) { + checks := FilesystemDiagnostics("", ".jsonl") + if len(checks) != 1 { + t.Fatalf("got %d checks, want 1", len(checks)) + } + + if checks[0].Status != "error" { + t.Fatalf("status = %q, want error", checks[0].Status) + } + + if checks[0].Name != "data-dir" { + t.Fatalf("name = %q, want data-dir", checks[0].Name) + } + }) + + t.Run("missing dir returns warn check", func(t *testing.T) { + checks := FilesystemDiagnostics("/nonexistent/path/xyz", ".jsonl") + if len(checks) != 1 { + t.Fatalf("got %d checks, want 1", len(checks)) + } + + if checks[0].Status != "warn" { + t.Fatalf("status = %q, want warn", checks[0].Status) + } + }) + + t.Run("valid dir with matching files", func(t *testing.T) { + dir := t.TempDir() + for _, name := range []string{"a.jsonl", "b.jsonl", "c.jsonl"} { + if err := os.WriteFile(filepath.Join(dir, name), []byte("{}"), 0o644); err != nil { + t.Fatal(err) + } + } + + checks := FilesystemDiagnostics(dir, ".jsonl") + if len(checks) != 2 { + t.Fatalf("got %d checks, want 2", len(checks)) + } + + if checks[0].Status != "ok" || checks[0].Name != "data-dir" { + t.Fatalf("check[0] = %+v, want ok/data-dir", checks[0]) + } + + if checks[1].Status != "ok" || checks[1].Name != "session-files" { + t.Fatalf("check[1] = %+v, want ok/session-files", checks[1]) + } + + if !strings.Contains(checks[1].Detail, "3") { + t.Fatalf("detail = %q, want count 3", checks[1].Detail) + } + }) + + t.Run("valid dir with no matching files", func(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "readme.txt"), []byte("hi"), 0o644); err != nil { + t.Fatal(err) + } + + checks := FilesystemDiagnostics(dir, ".jsonl") + if len(checks) != 2 { + t.Fatalf("got %d checks, want 2", len(checks)) + } + + if !strings.Contains(checks[1].Detail, "0") { + t.Fatalf("detail = %q, want count 0", checks[1].Detail) + } + }) + + t.Run("nested directories counted", func(t *testing.T) { + dir := t.TempDir() + + nested := filepath.Join(dir, "sub") + if err := os.Mkdir(nested, 0o755); err != nil { + t.Fatal(err) + } + + if err := os.WriteFile(filepath.Join(dir, "top.jsonl"), []byte("{}"), 0o644); err != nil { + t.Fatal(err) + } + + if err := os.WriteFile(filepath.Join(nested, "deep.jsonl"), []byte("{}"), 0o644); err != nil { + t.Fatal(err) + } + + checks := FilesystemDiagnostics(dir, ".jsonl") + if !strings.Contains(checks[1].Detail, "2") { + t.Fatalf("detail = %q, want count 2 (nested)", checks[1].Detail) + } + }) +} diff --git a/internal/adapter/agent_launch.go b/internal/adapter/agent_launch.go new file mode 100644 index 0000000..95eef5a --- /dev/null +++ b/internal/adapter/agent_launch.go @@ -0,0 +1,93 @@ +package adapter + +import ( + "encoding/json" + "strings" + + "github.com/cosmtrek/mindwalk/internal/model" +) + +// AgentLaunch records one agent-tool invocation the parent session made +// and the result the harness reported for it. Adapters (codex, crush) +// populate these from their harness-specific event streams and then +// stitch them to child sessions to render the agent graph. +// +// The fields are exported because each adapter reads only a subset: +// `arguments` carries the tool input (used for role/instruction), the +// `output`/`outputObserved` pair drives the unlinked-node status, and +// `seq` preserves launch order. Centralising the shape lets future +// adapters reuse the parsing/linking logic without re-defining a +// parallel struct. +type AgentLaunch struct { + CallID string + Seq int + Arguments map[string]any + Output string + OutputObserved bool +} + +// AgentLaunchOutput is the structured payload some harnesses return +// from an agent-tool result (codex embeds it in the function_call_output +// item; crush renders it as JSON inside the message parts). Keeping the +// JSON tags next to the field declarations makes the wire contract +// obvious in one place. +type AgentLaunchOutput struct { + AgentID string `json:"agent_id"` + Nickname string `json:"nickname"` + TaskName string `json:"task_name"` +} + +// SubagentLabel is the placeholder an agent-graph node falls back to +// when no launch nickname, child title, or tool-call id is available. +// Centralised so the user-visible string only changes in one place +// across every adapter that renders subagent nodes. +const SubagentLabel = "Subagent" + +// ApplySubagentLabel sets node.Label to SubagentLabel when the current +// label is empty. Adapters call this as the final fallback after +// preferring a child-provided label and a launch nickname. +func ApplySubagentLabel(node *model.AgentNode) { + if node.Label == "" { + node.Label = SubagentLabel + } +} + +// UnlinkedLaunchStatus inspects a launch record's output and marks +// the agent as failed when the harness emitted an observation that is +// neither valid JSON nor an empty payload. Adapters share the rule +// so "spawn returned garbage" looks the same across harnesses. +func UnlinkedLaunchStatus(launch AgentLaunch) string { + trimmed := strings.TrimSpace(launch.Output) + if launch.OutputObserved && trimmed != "" && !json.Valid([]byte(trimmed)) { + return model.AgentStatusFailed + } + + return model.AgentStatusUnknown +} + +// GraphActor records the parent's session metadata and its position +// in the agent graph while a BFS layer is being processed. Both the +// codex and crush adapters build their agent graph with the same +// shape (session → nodeID → depth → harness-specific source ID), so +// the struct lives here to keep both adapters' build loops on the +// same shape. +type GraphActor struct { + Session model.SessionMeta + NodeID string + Depth int + SourceID string +} + +// ApplyLaunchNickname sets node.Label to the launch output's nickname +// when the current label is empty, then falls back to the shared +// SubagentLabel if neither the caller nor the launch provided one. +// Adapters call this at the tail of every exact-link agent-node +// builder so the visible label follows the same precedence +// (child-provided > launch nickname > "Subagent") across harnesses. +func ApplyLaunchNickname(node *model.AgentNode, output AgentLaunchOutput) { + if node.Label == "" { + node.Label = output.Nickname + } + + ApplySubagentLabel(node) +} diff --git a/internal/adapter/claudecode/adapter.go b/internal/adapter/claudecode/adapter.go index e5692a8..6244edb 100644 --- a/internal/adapter/claudecode/adapter.go +++ b/internal/adapter/claudecode/adapter.go @@ -2,7 +2,6 @@ package claudecode import ( "encoding/json" - "fmt" "os" "path/filepath" "sort" @@ -16,18 +15,6 @@ type Adapter struct { Dir string } -func DefaultDir() string { - home, err := os.UserHomeDir() - if err != nil { - return "" - } - return filepath.Join(home, ".claude", "projects") -} - -func (a Adapter) Harness() string { - return "claude-code" -} - func (a Adapter) SessionDir() string { if a.Dir != "" { return a.Dir @@ -35,9 +22,17 @@ func (a Adapter) SessionDir() string { return DefaultDir() } +func DefaultDir() string { + return adapter.HomePath(".claude", "projects") +} + +func (a Adapter) Harness() string { + return "claude-code" +} + func (a Adapter) ListSessions() ([]model.SessionMeta, error) { dir := a.SessionDir() - if info, err := os.Stat(dir); err != nil || !info.IsDir() { + if !adapter.ReadableDir(dir) { return nil, nil } var metas []model.SessionMeta @@ -73,15 +68,16 @@ func (a Adapter) SummaryInputs(path string) []string { if !strings.HasPrefix(filepath.Base(path), "agent-") { return nil } + return []string{strings.TrimSuffix(path, ".jsonl") + ".meta.json"} } func (a Adapter) Summarize(path string) (model.SessionMeta, error) { - f, err := os.Open(path) + f, closeFile, err := adapter.OpenFile(path) if err != nil { return model.SessionMeta{}, err } - defer f.Close() + defer closeFile() id := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)) meta := model.SessionMeta{Key: adapter.SessionKey(a.Harness(), path), ID: id, Harness: a.Harness(), Path: path} @@ -133,7 +129,8 @@ func (a Adapter) Summarize(path string) (model.SessionMeta, error) { meta.EventCount += countToolUses(msg.Content) // mirrors Parse's user-message mark filter so the badge's // staleness check counts the same turns the report will - if line.Type == "user" && hasUserMessage(msg.Content) && !adapter.InjectedUserMessage(userMessageText(msg.Content)) { + if line.Type == "user" && hasUserMessage(msg.Content) && + !adapter.InjectedUserMessage(userMessageText(msg.Content)) { meta.UserTurns++ } } @@ -154,21 +151,19 @@ func (a Adapter) Summarize(path string) (model.SessionMeta, error) { meta.Agent.LaunchCallID = childMeta.ToolUseID } } - if meta.Title == "" { - meta.Title = filepath.Base(path) - } + adapter.FallbackSessionTitle(&meta, path) if !recognized { - return model.SessionMeta{}, fmt.Errorf("not a Claude Code session: %s", path) + return model.SessionMeta{}, adapter.NotRecognizedErr("Claude Code", path) } return meta, err } func (a Adapter) Parse(path string) (*model.Trace, error) { - f, err := os.Open(path) + f, closeFile, err := adapter.OpenFile(path) if err != nil { return nil, err } - defer f.Close() + defer closeFile() id := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)) trace := &model.Trace{ @@ -231,7 +226,10 @@ func (a Adapter) Parse(path string) (*model.Trace, error) { Timestamp: line.Timestamp, } if call.Name == "Task" || call.Name == "Agent" { - trace.Marks = append(trace.Marks, model.Mark{Seq: len(trace.Events), Type: "subagent", Note: call.Name}) + trace.Marks = append( + trace.Marks, + model.Mark{Seq: len(trace.Events), Type: "subagent", Note: call.Name}, + ) } if _, exists := pending[call.ID]; !exists { pendingOrder = append(pendingOrder, call.ID) @@ -263,7 +261,7 @@ func (a Adapter) Parse(path string) (*model.Trace, error) { // Claude Code tool results carry an is_error flag set by the harness. trace.Stats = model.ComputeStats(trace, 0, model.ObservabilitySignals{Errors: model.ObservabilityExact}) if !recognized { - return nil, fmt.Errorf("not a Claude Code session: %s", path) + return nil, adapter.NotRecognizedErr("Claude Code", path) } return trace, err } @@ -308,22 +306,28 @@ func (c *contentList) UnmarshalJSON(data []byte) error { if err := json.Unmarshal(data, &items); err != nil { return err } + var rawItems []map[string]json.RawMessage if err := json.Unmarshal(data, &rawItems); err != nil { return err } + for i := range items { items[i].IsError = nil + raw, ok := rawItems[i]["is_error"] if !ok || strings.TrimSpace(string(raw)) == "null" { continue } + var value bool if err := json.Unmarshal(raw, &value); err != nil { return err } + items[i].IsError = &value } + c.Items = items return nil } @@ -407,6 +411,7 @@ func isClaudeLine(line rawLine) bool { func buildEvent(trace *model.Trace, call adapter.ToolCall, result contentItem, resultObserved bool) model.Event { isError := result.IsError != nil && *result.IsError + return adapter.BuildEvent(trace, call, adapter.ToolResult{ Content: adapter.ContentToString(result.Content), IsError: isError, diff --git a/internal/adapter/claudecode/adapter_test.go b/internal/adapter/claudecode/adapter_test.go index 60f880e..988a2e4 100644 --- a/internal/adapter/claudecode/adapter_test.go +++ b/internal/adapter/claudecode/adapter_test.go @@ -12,29 +12,37 @@ func TestParseSession(t *testing.T) { if err != nil { t.Fatal(err) } + if trace.Session.ID != "demo-session" { t.Fatalf("session id = %q", trace.Session.ID) } + if trace.Session.Title != "Demo trace" { t.Fatalf("title = %q", trace.Session.Title) } + if trace.Session.Model != "claude-test" { t.Fatalf("model = %q", trace.Session.Model) } + if len(trace.Events) != 2 { t.Fatalf("events = %d", len(trace.Events)) } + read := trace.Events[0] if read.Action != "read" || len(read.Targets) != 1 || read.Targets[0].Path != "src/main.go" { t.Fatalf("bad read event: %#v", read) } + if got := read.Targets[0].Lines[0]; got != [2]int{10, 14} { t.Fatalf("line range = %#v", got) } + edit := trace.Events[1] if edit.Action != "edit" || edit.Targets[0].Touch != "edit" { t.Fatalf("bad edit event: %#v", edit) } + if trace.Stats.Edited != 1 || trace.Stats.Fovea != 1 { t.Fatalf("stats = %#v", trace.Stats) } @@ -45,26 +53,40 @@ func TestParseSkipsBashNoiseAndKeepsExistingWeakPaths(t *testing.T) { if err := os.MkdirAll(filepath.Join(root, "src"), 0o755); err != nil { t.Fatal(err) } + if err := os.WriteFile(filepath.Join(root, "src", "main.go"), []byte("package main\n"), 0o644); err != nil { t.Fatal(err) } + session := filepath.Join(t.TempDir(), "session.jsonl") - writeSession(t, session, - `{"type":"user","timestamp":"2026-07-09T00:00:00Z","cwd":`+quote(root)+`,"sessionId":"noise","message":{"role":"user","content":"x"}}`, - `{"type":"assistant","timestamp":"2026-07-09T00:00:01Z","cwd":`+quote(root)+`,"sessionId":"noise","message":{"role":"assistant","content":[{"type":"tool_use","id":"bash","name":"Bash","input":{"command":"printf 'please check the main.go file\nupdated to v1.5\n+ cs.fontSize = 27.2px\nsrc/main.go\n'"}}]}}`, - `{"type":"user","timestamp":"2026-07-09T00:00:02Z","cwd":`+quote(root)+`,"sessionId":"noise","message":{"role":"user","content":[{"tool_use_id":"bash","type":"tool_result","content":"please check the main.go file\nupdated to v1.5\n+ cs.fontSize = 27.2px\nsrc/main.go\n","is_error":false}]}}`, + writeSession( + t, + session, + `{"type":"user","timestamp":"2026-07-09T00:00:00Z","cwd":`+quote( + root, + )+`,"sessionId":"noise","message":{"role":"user","content":"x"}}`, + `{"type":"assistant","timestamp":"2026-07-09T00:00:01Z","cwd":`+quote( + root, + )+`,"sessionId":"noise","message":{"role":"assistant","content":[{"type":"tool_use","id":"bash","name":"Bash","input":{"command":"printf 'please check the main.go file\nupdated to v1.5\n+ cs.fontSize = 27.2px\nsrc/main.go\n'"}}]}}`, + `{"type":"user","timestamp":"2026-07-09T00:00:02Z","cwd":`+quote( + root, + )+`,"sessionId":"noise","message":{"role":"user","content":[{"tool_use_id":"bash","type":"tool_result","content":"please check the main.go file\nupdated to v1.5\n+ cs.fontSize = 27.2px\nsrc/main.go\n","is_error":false}]}}`, ) + trace, err := (Adapter{}).Parse(session) if err != nil { t.Fatal(err) } + if len(trace.Events) != 1 { t.Fatalf("events = %d", len(trace.Events)) } + targets := trace.Events[0].Targets if len(targets) != 1 { t.Fatalf("targets = %#v", targets) } + if targets[0].Path != "src/main.go" || !targets[0].Weak { t.Fatalf("target = %#v", targets[0]) } @@ -72,16 +94,25 @@ func TestParseSkipsBashNoiseAndKeepsExistingWeakPaths(t *testing.T) { func TestParsePendingToolUsesRemainDeterministic(t *testing.T) { session := filepath.Join(t.TempDir(), "pending.jsonl") - writeSession(t, session, + writeSession( + t, + session, `{"type":"user","timestamp":"2026-07-09T00:00:00Z","cwd":"/tmp","sessionId":"pending","message":{"role":"user","content":"x"}}`, `{"type":"assistant","timestamp":"2026-07-09T00:00:01Z","cwd":"/tmp","sessionId":"pending","message":{"role":"assistant","content":[{"type":"tool_use","id":"a","name":"Read","input":{"file_path":"/tmp/a.go"}},{"type":"tool_use","id":"b","name":"Read","input":{"file_path":"/tmp/b.go"}},{"type":"tool_use","id":"c","name":"Read","input":{"file_path":"/tmp/c.go"}}]}}`, ) - for i := 0; i < 20; i++ { + + for i := range 20 { trace, err := (Adapter{}).Parse(session) if err != nil { t.Fatal(err) } - got := []string{trace.Events[0].Targets[0].Path, trace.Events[1].Targets[0].Path, trace.Events[2].Targets[0].Path} + + got := []string{ + trace.Events[0].Targets[0].Path, + trace.Events[1].Targets[0].Path, + trace.Events[2].Targets[0].Path, + } + want := []string{"a.go", "b.go", "c.go"} for j := range want { if got[j] != want[j] { @@ -93,7 +124,9 @@ func TestParsePendingToolUsesRemainDeterministic(t *testing.T) { func TestParsePreservesToolOutcomeCertainty(t *testing.T) { session := filepath.Join(t.TempDir(), "outcome-certainty.jsonl") - writeSession(t, session, + writeSession( + t, + session, `{"type":"user","timestamp":"2026-07-09T00:00:00Z","cwd":"/tmp","sessionId":"outcome-certainty","message":{"role":"user","content":"x"}}`, `{"type":"assistant","timestamp":"2026-07-09T00:00:01Z","cwd":"/tmp","sessionId":"outcome-certainty","message":{"role":"assistant","content":[{"type":"tool_use","id":"explicit-success","name":"Bash","input":{"command":"go test ./..."}},{"type":"tool_use","id":"failure","name":"Bash","input":{"command":"make test"}},{"type":"tool_use","id":"implicit-success","name":"Bash","input":{"command":"npm test"}},{"type":"tool_use","id":"wrong-case","name":"Bash","input":{"command":"go test ./internal/..."}},{"type":"tool_use","id":"pending","name":"Bash","input":{"command":"go vet ./..."}}]}}`, `{"type":"user","timestamp":"2026-07-09T00:00:02Z","cwd":"/tmp","sessionId":"outcome-certainty","message":{"role":"user","content":[{"tool_use_id":"explicit-success","type":"tool_result","content":"ok","is_error":false},{"tool_use_id":"failure","type":"tool_result","content":"failed","is_error":true},{"tool_use_id":"implicit-success","type":"tool_result","content":"ok"},{"tool_use_id":"wrong-case","type":"tool_result","content":"ok","IS_ERROR":true}]}}`, @@ -103,24 +136,31 @@ func TestParsePreservesToolOutcomeCertainty(t *testing.T) { if err != nil { t.Fatal(err) } + if len(trace.Events) != 5 { t.Fatalf("events = %#v", trace.Events) } + if got := trace.Events[0]; !got.OutcomeKnown || got.IsError { t.Fatalf("success event = %#v", got) } + if got := trace.Events[1]; !got.OutcomeKnown || !got.IsError { t.Fatalf("failure event = %#v", got) } + if got := trace.Events[2]; !got.OutcomeKnown || got.IsError { t.Fatalf("implicit success event = %#v", got) } + if got := trace.Events[3]; !got.OutcomeKnown || got.IsError { t.Fatalf("wrong-case error key event = %#v", got) } + if got := trace.Events[4]; got.OutcomeKnown || got.IsError { t.Fatalf("pending event = %#v", got) } + if trace.Stats.Observability.Errors != "estimated" { t.Fatalf("observability = %#v", trace.Stats.Observability) } @@ -128,14 +168,18 @@ func TestParsePreservesToolOutcomeCertainty(t *testing.T) { func TestCompactionUsesSubtypeOnly(t *testing.T) { session := filepath.Join(t.TempDir(), "compact.jsonl") - writeSession(t, session, + writeSession( + t, + session, `{"type":"system","subtype":"local_command","content":"compact but not a boundary","timestamp":"2026-07-09T00:00:00Z","sessionId":"compact"}`, `{"type":"system","subtype":"compact_boundary","content":"","timestamp":"2026-07-09T00:00:01Z","sessionId":"compact"}`, ) + trace, err := (Adapter{}).Parse(session) if err != nil { t.Fatal(err) } + if len(trace.Marks) != 1 || trace.Marks[0].Type != "compaction" { t.Fatalf("marks = %#v", trace.Marks) } @@ -144,19 +188,24 @@ func TestCompactionUsesSubtypeOnly(t *testing.T) { func TestUserMessageMarkCarriesNote(t *testing.T) { long := strings.Repeat("字", 2100) session := filepath.Join(t.TempDir(), "notes.jsonl") - writeSession(t, session, + writeSession( + t, + session, `{"type":"user","timestamp":"2026-07-09T00:00:00Z","cwd":"/tmp","sessionId":"notes","message":{"role":"user","content":"fix the login bug"}}`, `{"type":"assistant","timestamp":"2026-07-09T00:00:01Z","cwd":"/tmp","sessionId":"notes","message":{"role":"assistant","content":[{"type":"tool_use","id":"a","name":"Read","input":{"file_path":"/tmp/a.go"}}]}}`, `{"type":"user","timestamp":"2026-07-09T00:00:02Z","cwd":"/tmp","sessionId":"notes","message":{"role":"user","content":[{"tool_use_id":"a","type":"tool_result","content":"ok","is_error":false}]}}`, `{"type":"user","timestamp":"2026-07-09T00:00:03Z","cwd":"/tmp","sessionId":"notes","message":{"role":"user","content":"`+long+`"}}`, ) + trace, err := (Adapter{}).Parse(session) if err != nil { t.Fatal(err) } + if len(trace.Marks) != 2 { t.Fatalf("marks = %#v", trace.Marks) } + if trace.Marks[0].Note != "fix the login bug" { t.Fatalf("note = %q", trace.Marks[0].Note) } @@ -171,6 +220,7 @@ func TestUserMessageMarkCarriesNote(t *testing.T) { if err != nil { t.Fatal(err) } + if meta.UserTurns != 2 { t.Fatalf("summarized userTurns = %d, want 2", meta.UserTurns) } @@ -181,10 +231,12 @@ func TestSummarizeRecognizesUnknownClaudeLineWithSessionID(t *testing.T) { writeSession(t, session, `{"type":"metadata","timestamp":"2026-07-09T00:00:00Z","sessionId":"metadata-session","cwd":"/tmp"}`, ) + meta, err := (Adapter{}).Summarize(session) if err != nil { t.Fatal(err) } + if meta.ID != "metadata-session" || meta.Cwd != "/tmp" { t.Fatalf("meta = %#v", meta) } @@ -195,11 +247,19 @@ func TestSummarizePopulatesSidechainMetadata(t *testing.T) { if err := os.MkdirAll(rootDir, 0o755); err != nil { t.Fatal(err) } + session := filepath.Join(rootDir, "agent-child.jsonl") - writeSession(t, session, + writeSession( + t, + session, `{"type":"user","timestamp":"2026-07-09T00:00:00Z","sessionId":"root-session","agentId":"child","isSidechain":true,"cwd":"/tmp","message":{"role":"user","content":"hello"}}`, ) - if err := os.WriteFile(strings.TrimSuffix(session, ".jsonl")+".meta.json", []byte(`{"agentType":"Explore","description":"Inspect child","toolUseId":"call-child","spawnDepth":2}`), 0o644); err != nil { + + if err := os.WriteFile( + strings.TrimSuffix(session, ".jsonl")+".meta.json", + []byte(`{"agentType":"Explore","description":"Inspect child","toolUseId":"call-child","spawnDepth":2}`), + 0o644, + ); err != nil { t.Fatal(err) } @@ -207,12 +267,15 @@ func TestSummarizePopulatesSidechainMetadata(t *testing.T) { if err != nil { t.Fatal(err) } + if meta.ID != "child" { t.Fatalf("id = %q, want child", meta.ID) } + if !meta.Auxiliary || meta.Agent == nil { t.Fatalf("meta = %#v", meta) } + if meta.Agent.SourceID != "child" || meta.Agent.RootSessionID != "root-session" || meta.Agent.Depth != 2 || meta.Agent.Role != "Explore" || meta.Agent.LaunchCallID != "call-child" { t.Fatalf("agent meta = %#v", *meta.Agent) @@ -223,22 +286,28 @@ func TestSummarizeHandlesLargeJSONLines(t *testing.T) { dir := t.TempDir() session := filepath.Join(dir, "large.jsonl") largeText := strings.Repeat("x", 21*1024*1024) - writeSession(t, session, + writeSession( + t, + session, `{"type":"user","timestamp":"2026-07-09T00:00:00Z","cwd":"/tmp","sessionId":"large","message":{"role":"user","content":"`+largeText+`"}}`, ) adapter := Adapter{Dir: dir} + meta, err := adapter.Summarize(session) if err != nil { t.Fatal(err) } + if meta.ID != "large" { t.Fatalf("id = %q", meta.ID) } + sessions, err := adapter.ListSessions() if err != nil { t.Fatal(err) } + if len(sessions) != 1 || sessions[0].ID != "large" { t.Fatalf("sessions = %#v", sessions) } @@ -246,11 +315,13 @@ func TestSummarizeHandlesLargeJSONLines(t *testing.T) { func writeSession(t *testing.T, path string, lines ...string) { t.Helper() - content := "" + + var content strings.Builder for _, line := range lines { - content += line + "\n" + content.WriteString(line + "\n") } - if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + + if err := os.WriteFile(path, []byte(content.String()), 0o644); err != nil { t.Fatal(err) } } diff --git a/internal/adapter/claudecode/agents.go b/internal/adapter/claudecode/agents.go index ab0ce50..ea634d4 100644 --- a/internal/adapter/claudecode/agents.go +++ b/internal/adapter/claudecode/agents.go @@ -2,6 +2,7 @@ package claudecode import ( "encoding/json" + "fmt" "os" "path/filepath" "sort" @@ -39,20 +40,27 @@ type claudeAgentArtifact struct { func (a Adapter) AgentGraphInputs(root model.SessionMeta, _ []model.SessionMeta) ([]string, error) { inputs := []string{root.Path} subagentsDir := filepath.Join(filepath.Dir(root.Path), root.ID, "subagents") + entries, err := os.ReadDir(subagentsDir) if os.IsNotExist(err) { return inputs, nil } + if err != nil { - return nil, err + return nil, fmt.Errorf("read subagents dir %s: %w", subagentsDir, err) } + for _, entry := range entries { - if entry.IsDir() || (!strings.HasSuffix(entry.Name(), ".jsonl") && !strings.HasSuffix(entry.Name(), ".meta.json")) { + if entry.IsDir() || + (!strings.HasSuffix(entry.Name(), ".jsonl") && !strings.HasSuffix(entry.Name(), ".meta.json")) { continue } + inputs = append(inputs, filepath.Join(subagentsDir, entry.Name())) } + sort.Strings(inputs) + return inputs, nil } @@ -76,28 +84,34 @@ func (a Adapter) BuildAgentGraph(root model.SessionMeta, catalog []model.Session } subagentsDir := filepath.Join(filepath.Dir(root.Path), root.ID, "subagents") + artifacts, err := discoverClaudeAgentArtifacts(subagentsDir, catalog) if err != nil { - return nil, err + return nil, fmt.Errorf("discover agent artifacts in %s: %w", subagentsDir, err) } rootLaunches, err := readClaudeAgentLaunches(root.Path) if err != nil { - return nil, err + return nil, fmt.Errorf("read agent launches from %s: %w", root.Path, err) } + launches := append([]*claudeAgentLaunch(nil), rootLaunches...) + launchByCallID := make(map[string]*claudeAgentLaunch) for _, launch := range rootLaunches { launchByCallID[launch.callID] = launch } + for _, artifact := range artifacts { if artifact.session == nil { continue } + actorLaunches, readErr := readClaudeAgentLaunches(artifact.session.Path) if readErr != nil { - return nil, readErr + return nil, fmt.Errorf("read agent launches from %s: %w", artifact.session.Path, readErr) } + for _, launch := range actorLaunches { launch.owner = artifact if existing := launchByCallID[launch.callID]; existing != nil { @@ -105,8 +119,10 @@ func (a Adapter) BuildAgentGraph(root model.SessionMeta, catalog []model.Session existing.resultObserved = true existing.resultError = launch.resultError } + continue } + launchByCallID[launch.callID] = launch launches = append(launches, launch) } @@ -117,38 +133,48 @@ func (a Adapter) BuildAgentGraph(root model.SessionMeta, catalog []model.Session if artifact.sidecar != nil && artifact.sidecar.ToolUseID != "" { identity = "claude-tool:" + artifact.sidecar.ToolUseID } + artifact.nodeID = adapter.AgentNodeID(a.Harness(), root.Key, identity) } + for _, artifact := range artifacts { resolveClaudeArtifactDepth(artifact, launchByCallID, map[*claudeAgentArtifact]bool{}) } matchedLaunches := make(map[string]bool) + for _, artifact := range artifacts { node, launch := claudeArtifactNode(root.Key, mainID, artifact, launchByCallID) graph.Agents = append(graph.Agents, node) + if launch != nil { matchedLaunches[launch.callID] = true } } + for _, launch := range launches { if matchedLaunches[launch.callID] { continue } + graph.Agents = append(graph.Agents, unlinkedClaudeLaunchNode(a.Harness(), root.Key, mainID, launch)) } graph.Agents = adapter.OrderAgentNodesPreorder(graph.Agents) + return graph, nil } func discoverClaudeAgentArtifacts(subagentsDir string, catalog []model.SessionMeta) ([]*claudeAgentArtifact, error) { byBasename := make(map[string]*claudeAgentArtifact) + for i := range catalog { session := &catalog[i] - if filepath.Clean(filepath.Dir(session.Path)) != filepath.Clean(subagentsDir) || filepath.Ext(session.Path) != ".jsonl" { + if filepath.Clean(filepath.Dir(session.Path)) != filepath.Clean(subagentsDir) || + filepath.Ext(session.Path) != ".jsonl" { continue } + basename := strings.TrimSuffix(filepath.Base(session.Path), ".jsonl") byBasename[basename] = &claudeAgentArtifact{jsonPath: session.Path, session: session} } @@ -157,14 +183,18 @@ func discoverClaudeAgentArtifacts(subagentsDir string, catalog []model.SessionMe if os.IsNotExist(err) { return sortedClaudeArtifacts(byBasename), nil } + if err != nil { - return nil, err + return nil, fmt.Errorf("read subagents dir %s: %w", subagentsDir, err) } + for _, entry := range entries { if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".meta.json") { continue } + basename := strings.TrimSuffix(entry.Name(), ".meta.json") + artifact := byBasename[basename] if artifact == nil { artifact = &claudeAgentArtifact{ @@ -172,15 +202,18 @@ func discoverClaudeAgentArtifacts(subagentsDir string, catalog []model.SessionMe } byBasename[basename] = artifact } + data, readErr := os.ReadFile(filepath.Join(subagentsDir, entry.Name())) if readErr != nil { - return nil, readErr + return nil, fmt.Errorf("read sidecar %s: %w", filepath.Join(subagentsDir, entry.Name()), readErr) } + var sidecar claudeChildSidecar if json.Unmarshal(data, &sidecar) == nil { artifact.sidecar = &sidecar } } + return sortedClaudeArtifacts(byBasename), nil } @@ -189,20 +222,23 @@ func sortedClaudeArtifacts(byBasename map[string]*claudeAgentArtifact) []*claude for basename := range byBasename { basenames = append(basenames, basename) } + sort.Strings(basenames) + artifacts := make([]*claudeAgentArtifact, 0, len(basenames)) for _, basename := range basenames { artifacts = append(artifacts, byBasename[basename]) } + return artifacts } func readClaudeAgentLaunches(path string) ([]*claudeAgentLaunch, error) { - f, err := os.Open(path) + f, closeFile, err := adapter.OpenFile(path) if err != nil { - return nil, err + return nil, fmt.Errorf("open agent session %s: %w", path, err) } - defer f.Close() + defer closeFile() launches := []*claudeAgentLaunch{} launchByCallID := make(map[string]*claudeAgentLaunch) @@ -213,47 +249,60 @@ func readClaudeAgentLaunches(path string) ([]*claudeAgentLaunch, error) { if json.Unmarshal(data, &line) != nil || len(line.Message) == 0 { return } + var msg message if json.Unmarshal(line.Message, &msg) != nil { return } + for _, item := range msg.Content.Items { switch item.Type { case "tool_use": if seenCalls[item.ID] { continue } + seenCalls[item.ID] = true if item.Name == "Agent" || item.Name == "Task" { launch := &claudeAgentLaunch{callID: item.ID, seq: seq, input: item.Input} launches = append(launches, launch) launchByCallID[item.ID] = launch } + seq++ case "tool_result": launch := launchByCallID[item.ToolUseID] if launch == nil || launch.resultObserved { continue } + launch.resultObserved = true launch.resultError = item.IsError != nil && *item.IsError } } }) + return launches, err } -func claudeArtifactNode(rootKey, mainID string, artifact *claudeAgentArtifact, launchByCallID map[string]*claudeAgentLaunch) (model.AgentNode, *claudeAgentLaunch) { +func claudeArtifactNode( + rootKey, mainID string, + artifact *claudeAgentArtifact, + launchByCallID map[string]*claudeAgentLaunch, +) (model.AgentNode, *claudeAgentLaunch) { var launch *claudeAgentLaunch if artifact.sidecar != nil && artifact.sidecar.ToolUseID != "" { launch = launchByCallID[artifact.sidecar.ToolUseID] } + parentID := mainID quality := model.AgentLinkQualityDerived method := model.AgentLinkMethodClaudeSubagentsDirectory + if launch != nil && launch.owner != artifact { quality = model.AgentLinkQualityExact method = model.AgentLinkMethodClaudeToolUseID + if launch.owner != nil { parentID = launch.owner.nodeID } @@ -279,33 +328,41 @@ func claudeArtifactNode(rootKey, mainID string, artifact *claudeAgentArtifact, l node.LaunchSeq = &seq node.LaunchCallID = launch.callID } + if artifact.session != nil { node.TraceAvailability = model.TraceAvailabilityAvailable node.TraceSessionKey = artifact.session.Key node.TraceEventCount = artifact.session.EventCount } + return node, launch } func unlinkedClaudeLaunchNode(harness, rootKey, mainID string, launch *claudeAgentLaunch) model.AgentNode { parentID := mainID parentDepth := 0 + if launch.owner != nil { parentID = launch.owner.nodeID parentDepth = launch.owner.depth } + status := model.AgentStatusUnknown if launch.resultObserved && launch.resultError { status = model.AgentStatusFailed } + seq := launch.seq + label := claudeLaunchInput(launch, "description") if label == "" { label = claudeLaunchInput(launch, "subagent_type") } + if label == "" { label = "Subagent" } + return model.AgentNode{ ID: adapter.AgentNodeID(harness, rootKey, "launch:"+parentID+":"+launch.callID), ParentID: parentID, @@ -323,26 +380,38 @@ func unlinkedClaudeLaunchNode(harness, rootKey, mainID string, launch *claudeAge } } -func resolveClaudeArtifactDepth(artifact *claudeAgentArtifact, launchByCallID map[string]*claudeAgentLaunch, visiting map[*claudeAgentArtifact]bool) int { +func resolveClaudeArtifactDepth( + artifact *claudeAgentArtifact, + launchByCallID map[string]*claudeAgentLaunch, + visiting map[*claudeAgentArtifact]bool, +) int { if artifact.depth > 0 { return artifact.depth } + if artifact.sidecar != nil && artifact.sidecar.SpawnDepth > 0 { artifact.depth = artifact.sidecar.SpawnDepth + return artifact.depth } + if visiting[artifact] { return 1 } + visiting[artifact] = true parentDepth := 0 + if artifact.sidecar != nil { - if launch := launchByCallID[artifact.sidecar.ToolUseID]; launch != nil && launch.owner != nil && launch.owner != artifact { + if launch := launchByCallID[artifact.sidecar.ToolUseID]; launch != nil && launch.owner != nil && + launch.owner != artifact { parentDepth = resolveClaudeArtifactDepth(launch.owner, launchByCallID, visiting) } } + delete(visiting, artifact) artifact.depth = parentDepth + 1 + return artifact.depth } @@ -350,6 +419,7 @@ func claudeArtifactSessionKey(harness string, artifact *claudeAgentArtifact) str if artifact.session != nil { return artifact.session.Key } + return adapter.SessionKey(harness, artifact.jsonPath) } @@ -361,6 +431,7 @@ func claudeArtifactLabel(artifact *claudeAgentArtifact, launch *claudeAgentLaunc } } } + if launch != nil { for _, key := range []string{"description", "subagent_type"} { if value := claudeLaunchInput(launch, key); value != "" { @@ -368,6 +439,7 @@ func claudeArtifactLabel(artifact *claudeAgentArtifact, launch *claudeAgentLaunc } } } + return "Subagent" } @@ -375,6 +447,7 @@ func claudeArtifactRole(artifact *claudeAgentArtifact, launch *claudeAgentLaunch if artifact.sidecar != nil && artifact.sidecar.AgentType != "" { return artifact.sidecar.AgentType } + return claudeLaunchInput(launch, "subagent_type") } @@ -382,6 +455,8 @@ func claudeLaunchInput(launch *claudeAgentLaunch, key string) string { if launch == nil { return "" } + value, _ := launch.input[key].(string) + return value } diff --git a/internal/adapter/claudecode/agents_test.go b/internal/adapter/claudecode/agents_test.go index d80b984..5a07852 100644 --- a/internal/adapter/claudecode/agents_test.go +++ b/internal/adapter/claudecode/agents_test.go @@ -38,9 +38,11 @@ func TestClaudeAgentGraphExactUsesToolUseIDForImmediateParent(t *testing.T) { if graph.Version != model.AgentGraphVersion || graph.RootSessionKey != fixture.root.Key { t.Fatalf("graph header = %#v", graph) } + if len(graph.Agents) != 3 || graph.Agents[0] != claudeMainNode(fixture.root) { t.Fatalf("agents = %#v", graph.Agents) } + childNode := findClaudeAgent(t, graph, "Child") childID := claudeAgentID(fixture.root, "claude-tool:call-child") assertClaudeAgent(t, childNode, model.AgentNode{ @@ -51,7 +53,7 @@ func TestClaudeAgentGraphExactUsesToolUseIDForImmediateParent(t *testing.T) { Label: "Child", Role: "Explore", InstructionPreview: "inspect the code", - LaunchSeq: intPointer(1), + LaunchSeq: new(1), LaunchCallID: "call-child", Status: model.AgentStatusLaunched, TraceAvailability: model.TraceAvailabilityAvailable, @@ -68,7 +70,7 @@ func TestClaudeAgentGraphExactUsesToolUseIDForImmediateParent(t *testing.T) { Label: "Grandchild", Role: "Plan", InstructionPreview: "plan the fix", - LaunchSeq: intPointer(0), + LaunchSeq: new(0), LaunchCallID: "call-grand", Status: model.AgentStatusLaunched, TraceAvailability: model.TraceAvailabilityAvailable, @@ -148,7 +150,7 @@ func TestClaudeAgentGraphMetadataOnlyChildIsMissing(t *testing.T) { Label: "Missing", Role: "Explore", InstructionPreview: "find the missing trace", - LaunchSeq: intPointer(0), + LaunchSeq: new(0), LaunchCallID: "call-missing", Status: model.AgentStatusLaunched, TraceAvailability: model.TraceAvailabilityMissing, @@ -167,7 +169,8 @@ func TestClaudeAgentGraphZeroEventChildIsAvailable(t *testing.T) { }, claudeChildMeta{Description: "Zero", ToolUseID: "call-zero", SpawnDepth: 1}) node := findClaudeAgent(t, fixture.build(t), "Zero") - if node.TraceAvailability != model.TraceAvailabilityAvailable || node.TraceSessionKey != child.Key || node.TraceEventCount != 0 { + if node.TraceAvailability != model.TraceAvailabilityAvailable || node.TraceSessionKey != child.Key || + node.TraceEventCount != 0 { t.Fatalf("zero-event node = %#v", node) } } @@ -189,7 +192,7 @@ func TestClaudeAgentGraphErrorResultIsFailed(t *testing.T) { Label: "Failed launch", Role: "Plan", InstructionPreview: "try it", - LaunchSeq: intPointer(0), + LaunchSeq: new(0), LaunchCallID: "call-failed", Status: model.AgentStatusFailed, TraceAvailability: model.TraceAvailabilityUnavailable, @@ -230,6 +233,7 @@ func TestClaudeAgentGraphDuplicateCallIDProducesOneNode(t *testing.T) { if len(graph.Agents) != 2 { t.Fatalf("agents = %#v, want Main plus one shared-call node", graph.Agents) } + if graph.Agents[1].Label != "Shared" || graph.Agents[1].TraceAvailability != model.TraceAvailabilityAvailable { t.Fatalf("shared node = %#v", graph.Agents[1]) } @@ -254,14 +258,17 @@ func TestClaudeAgentGraphUsesStablePreorder(t *testing.T) { }, claudeChildMeta{Description: "A1", ToolUseID: "call-a1", SpawnDepth: 2}) graph := fixture.build(t) + labels := make([]string, len(graph.Agents)) for i, node := range graph.Agents { labels[i] = node.Label } + want := []string{"Main", "A", "A1", "B"} if len(labels) != len(want) { t.Fatalf("agent order = %v, want %v", labels, want) } + for i := range want { if labels[i] != want[i] { t.Fatalf("agent order = %v, want %v", labels, want) @@ -280,6 +287,7 @@ func TestClaudeAgentGraphInputsIncludeChildTracesAndSidecars(t *testing.T) { if err != nil { t.Fatal(err) } + want := map[string]bool{ fixture.root.Path: true, child.Path: true, @@ -289,6 +297,7 @@ func TestClaudeAgentGraphInputsIncludeChildTracesAndSidecars(t *testing.T) { if len(inputs) != len(want) { t.Fatalf("graph inputs = %v, want %v", inputs, want) } + for _, path := range inputs { if !want[path] { t.Fatalf("unexpected graph input %q in %v", path, inputs) @@ -315,29 +324,42 @@ func newClaudeAgentFixture(t *testing.T, rootLines []any) *claudeAgentFixture { dir := t.TempDir() a := Adapter{Dir: dir} rootPath := filepath.Join(dir, "root-id.jsonl") + lines := append([]any{claudeUser("root-id", "root prompt")}, rootLines...) writeClaudeAgentJSONL(t, rootPath, lines...) + root, err := a.Summarize(rootPath) if err != nil { t.Fatal(err) } + subagentsDir := filepath.Join(dir, "root-id", "subagents") if err := os.MkdirAll(subagentsDir, 0o755); err != nil { t.Fatal(err) } + return &claudeAgentFixture{adapter: a, root: root, subagentsDir: subagentsDir} } -func (f *claudeAgentFixture) addChild(t *testing.T, basename string, lines []any, meta claudeChildMeta) model.SessionMeta { +func (f *claudeAgentFixture) addChild( + t *testing.T, + basename string, + lines []any, + meta claudeChildMeta, +) model.SessionMeta { t.Helper() + path := filepath.Join(f.subagentsDir, basename+".jsonl") writeClaudeAgentJSONL(t, path, lines...) f.writeMeta(t, basename, meta) + session, err := f.adapter.Summarize(path) if err != nil { t.Fatal(err) } + f.catalog = append(f.catalog, session) + return session } @@ -348,10 +370,12 @@ func (f *claudeAgentFixture) addMetaOnly(t *testing.T, basename string, meta cla func (f *claudeAgentFixture) writeMeta(t *testing.T, basename string, meta claudeChildMeta) { t.Helper() + data, err := json.Marshal(meta) if err != nil { t.Fatal(err) } + if err := os.WriteFile(filepath.Join(f.subagentsDir, basename+".meta.json"), data, 0o644); err != nil { t.Fatal(err) } @@ -359,10 +383,12 @@ func (f *claudeAgentFixture) writeMeta(t *testing.T, basename string, meta claud func (f *claudeAgentFixture) build(t *testing.T) *model.AgentGraph { t.Helper() + graph, err := f.adapter.BuildAgentGraph(f.root, f.catalog) if err != nil { t.Fatal(err) } + return graph } @@ -377,6 +403,7 @@ func claudeSidechainUser(sessionID, agentID, text string) map[string]any { line := claudeUser(sessionID, text) line["agentId"] = agentID line["isSidechain"] = true + return line } @@ -413,20 +440,25 @@ func claudeMessageItem(sessionID, agentID, role string, item map[string]any) map line["agentId"] = agentID line["isSidechain"] = true } + return line } func writeClaudeAgentJSONL(t *testing.T, path string, values ...any) { t.Helper() + var content []byte + for _, value := range values { line, err := json.Marshal(value) if err != nil { t.Fatal(err) } + content = append(content, line...) content = append(content, '\n') } + if err := os.WriteFile(path, content, 0o644); err != nil { t.Fatal(err) } @@ -434,18 +466,23 @@ func writeClaudeAgentJSONL(t *testing.T, path string, values ...any) { func findClaudeAgent(t *testing.T, graph *model.AgentGraph, label string) model.AgentNode { t.Helper() + for _, node := range graph.Agents { if node.Label == label { return node } } + t.Fatalf("agent %q not found in %#v", label, graph.Agents) + return model.AgentNode{} } func assertClaudeAgent(t *testing.T, got, want model.AgentNode) { t.Helper() + gotJSON, _ := json.Marshal(got) + wantJSON, _ := json.Marshal(want) if string(gotJSON) != string(wantJSON) { t.Fatalf("agent:\n got: %s\nwant: %s", gotJSON, wantJSON) @@ -474,7 +511,3 @@ func claudeMainID(root model.SessionMeta) string { func claudeAgentID(root model.SessionMeta, identity string) string { return baseadapter.AgentNodeID((Adapter{}).Harness(), root.Key, identity) } - -func intPointer(value int) *int { - return &value -} diff --git a/internal/adapter/claudecode/diagnostics.go b/internal/adapter/claudecode/diagnostics.go new file mode 100644 index 0000000..2d72d86 --- /dev/null +++ b/internal/adapter/claudecode/diagnostics.go @@ -0,0 +1,7 @@ +package claudecode + +import "github.com/cosmtrek/mindwalk/internal/adapter" + +func (a Adapter) Diagnostics() []adapter.DiagnosticCheck { + return adapter.FilesystemDiagnostics(a.SessionDir(), ".jsonl") +} diff --git a/internal/adapter/claudecode/summary_inputs_test.go b/internal/adapter/claudecode/summary_inputs_test.go index 4b56011..e3f96bb 100644 --- a/internal/adapter/claudecode/summary_inputs_test.go +++ b/internal/adapter/claudecode/summary_inputs_test.go @@ -9,10 +9,12 @@ func TestSummaryInputsDeclaresAgentSidecarOnly(t *testing.T) { a := Adapter{} child := filepath.Join("proj", "agent-abc.jsonl") inputs := a.SummaryInputs(child) + want := filepath.Join("proj", "agent-abc.meta.json") if len(inputs) != 1 || inputs[0] != want { t.Fatalf("SummaryInputs(%q) = %v, want [%s]", child, inputs, want) } + if inputs := a.SummaryInputs(filepath.Join("proj", "root.jsonl")); inputs != nil { t.Fatalf("main session declared sidecars: %v", inputs) } diff --git a/internal/adapter/codex/adapter.go b/internal/adapter/codex/adapter.go index 504d5f8..ed373ae 100644 --- a/internal/adapter/codex/adapter.go +++ b/internal/adapter/codex/adapter.go @@ -3,6 +3,7 @@ package codex import ( "encoding/json" "fmt" + "maps" "os" "path/filepath" "regexp" @@ -22,19 +23,11 @@ type Adapter struct { } func DefaultDir() string { - home, err := os.UserHomeDir() - if err != nil { - return "" - } - return filepath.Join(home, ".codex", "sessions") + return adapter.HomePath(".codex", "sessions") } func DefaultIndexPath() string { - home, err := os.UserHomeDir() - if err != nil { - return "" - } - return filepath.Join(home, ".codex", "session_index.jsonl") + return adapter.HomePath(".codex", "session_index.jsonl") } func (a Adapter) Harness() string { @@ -50,7 +43,7 @@ func (a Adapter) SessionDir() string { func (a Adapter) ListSessions() ([]model.SessionMeta, error) { dir := a.SessionDir() - if info, err := os.Stat(dir); err != nil || !info.IsDir() { + if !adapter.ReadableDir(dir) { return nil, nil } var metas []model.SessionMeta @@ -89,22 +82,25 @@ func (a Adapter) SummaryInputs(_ string) []string { if a.IndexPath != "" { return []string{a.IndexPath} } + var inputs []string if dir := filepath.Clean(a.SessionDir()); dir != "" && dir != "." { inputs = append(inputs, filepath.Join(filepath.Dir(dir), "session_index.jsonl")) } + if candidate := DefaultIndexPath(); candidate != "" && (len(inputs) == 0 || inputs[0] != candidate) { inputs = append(inputs, candidate) } + return inputs } func (a Adapter) Summarize(path string) (model.SessionMeta, error) { - f, err := os.Open(path) + f, closeFile, err := adapter.OpenFile(path) if err != nil { return model.SessionMeta{}, err } - defer f.Close() + defer closeFile() id := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)) meta := model.SessionMeta{Key: adapter.SessionKey(a.Harness(), path), ID: id, Harness: a.Harness(), Path: path} @@ -149,7 +145,8 @@ func (a Adapter) Summarize(path string) (model.SessionMeta, error) { recognized = true var payload responseItemHeader if json.Unmarshal(line.Payload, &payload) == nil { - if callID, ok := canonicalCallID(payload.Type, payload.ID, payload.CallID, payload.Name); ok && !seenCalls[callID] { + if callID, ok := canonicalCallID(payload.Type, payload.ID, payload.CallID, payload.Name); ok && + !seenCalls[callID] { seenCalls[callID] = true meta.EventCount++ } @@ -195,21 +192,19 @@ func (a Adapter) Summarize(path string) (model.SessionMeta, error) { if meta.Title == "" { meta.Title = a.titleFor(meta.ID) } - if meta.Title == "" { - meta.Title = filepath.Base(path) - } + adapter.FallbackSessionTitle(&meta, path) if !recognized { - return model.SessionMeta{}, fmt.Errorf("not a Codex session: %s", path) + return model.SessionMeta{}, adapter.NotRecognizedErr(a.Harness(), path) } return meta, err } func (a Adapter) Parse(path string) (*model.Trace, error) { - f, err := os.Open(path) + f, closeFile, err := adapter.OpenFile(path) if err != nil { return nil, err } - defer f.Close() + defer closeFile() id := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)) trace := &model.Trace{ @@ -267,7 +262,10 @@ func (a Adapter) Parse(path string) (*model.Trace, error) { return } if call.Name == "spawn_agent" { - trace.Marks = append(trace.Marks, model.Mark{Seq: len(callOrder), Type: "subagent", Note: call.Name}) + trace.Marks = append( + trace.Marks, + model.Mark{Seq: len(callOrder), Type: "subagent", Note: call.Name}, + ) } calls[call.ID] = call callOrder = append(callOrder, call.ID) @@ -282,6 +280,7 @@ func (a Adapter) Parse(path string) (*model.Trace, error) { if _, exists := results[callID]; exists { return } + result.IsError, result.OutcomeKnown = toolOutputStatus(call.Name, result.Content) results[callID] = result return @@ -319,6 +318,7 @@ func (a Adapter) Parse(path string) (*model.Trace, error) { if json.Unmarshal(line.Payload, &payload) != nil { return } + payload.Success = exactBoolPointer(line.Payload, "success") if payload.Type == "context_compacted" { trace.Marks = append(trace.Marks, model.Mark{Seq: len(callOrder), Type: "compaction"}) @@ -358,16 +358,14 @@ func (a Adapter) Parse(path string) (*model.Trace, error) { if trace.Session.Title == "" { trace.Session.Title = a.titleFor(trace.Session.ID) } - if trace.Session.Title == "" { - trace.Session.Title = filepath.Base(path) - } + adapter.FallbackTraceSessionTitle(trace, path) trace.Session.EventCount = len(trace.Events) // Codex logs carry no structural error flag; failures are inferred from // output text, and inner commands of the js exec runner surface only what // the script chooses to print. trace.Stats = model.ComputeStats(trace, 0, model.ObservabilitySignals{Errors: model.ObservabilityEstimated}) if !recognized { - return nil, fmt.Errorf("not a Codex session: %s", path) + return nil, adapter.NotRecognizedErr(a.Harness(), path) } return trace, err } @@ -645,42 +643,10 @@ func decodeOutput(payload responseItemPayload) (string, adapter.ToolResult, bool } func parseInput(raw json.RawMessage) map[string]any { - input := map[string]any{} if len(raw) == 0 || string(raw) == "null" { - return input - } - var value any - if json.Unmarshal(raw, &value) != nil { - input["_raw"] = string(raw) - return input - } - switch value := value.(type) { - case map[string]any: - return value - case string: - return parseInputText(value) - default: - encoded, _ := json.Marshal(value) - input["_raw"] = string(encoded) - return input + return map[string]any{} } -} - -func parseInputText(text string) map[string]any { - input := map[string]any{} - trimmed := strings.TrimSpace(text) - if trimmed == "" { - return input - } - if json.Unmarshal([]byte(trimmed), &input) == nil { - return input - } - var nested string - if json.Unmarshal([]byte(trimmed), &nested) == nil && nested != text { - return parseInputText(nested) - } - input["_raw"] = text - return input + return adapter.ParseJSONInput(string(raw)) } func applyPatchChanges(input map[string]any, changes map[string]patchApplyChange) map[string]any { @@ -688,9 +654,7 @@ func applyPatchChanges(input map[string]any, changes map[string]patchApplyChange return input } merged := make(map[string]any, len(input)+1) - for key, value := range input { - merged[key] = value - } + maps.Copy(merged, input) patch := "" for _, key := range []string{"patch", "input", "_raw"} { if value, ok := input[key].(string); ok { @@ -706,6 +670,9 @@ func applyPatchChanges(input map[string]any, changes map[string]patchApplyChange paths = append(paths, path) } sort.Strings(paths) + + var patchSb645 strings.Builder + for _, path := range paths { operation := "Update" switch strings.ToLower(changes[path].Type) { @@ -714,8 +681,12 @@ func applyPatchChanges(input map[string]any, changes map[string]patchApplyChange case "delete": operation = "Delete" } - patch += fmt.Sprintf("*** %s File: %s\n", operation, path) + + fmt.Fprintf(&patchSb645, "*** %s File: %s\n", operation, path) } + + patch += patchSb645.String() + merged["patch"] = patch return merged } @@ -724,12 +695,14 @@ var exitCodeRe = regexp.MustCompile(`(?im)^(?:Process exited with code|Exit code func commandOutputStatus(output string) (failed bool, known bool) { trimmed := strings.TrimSpace(output) + var envelope map[string]json.RawMessage if json.Unmarshal([]byte(trimmed), &envelope) == nil { if _, ok := exactString(envelope, "output"); ok { if exitCode, ok := exactInt(envelope, "exit_code"); ok { return exitCode != 0, true } + if raw, ok := envelope["metadata"]; ok { var metadata map[string]json.RawMessage if json.Unmarshal(raw, &metadata) == nil { @@ -739,6 +712,7 @@ func commandOutputStatus(output string) (failed bool, known bool) { } } } + if _, ok := exactString(envelope, "message"); ok { if timedOut, ok := exactBool(envelope, "timed_out"); ok && timedOut { return true, true @@ -753,6 +727,7 @@ func commandOutputStatus(output string) (failed bool, known bool) { if strings.HasPrefix(status, "script running with cell id ") { return false, false } + switch status { case "script completed": return false, true @@ -767,7 +742,7 @@ func commandOutputStatus(output string) (failed bool, known bool) { header = header[:index] } } - for _, line := range strings.Split(header, "\n") { + for line := range strings.SplitSeq(header, "\n") { if strings.EqualFold(strings.TrimSpace(line), "aborted by user") { return true, true } @@ -776,6 +751,7 @@ func commandOutputStatus(output string) (failed bool, known bool) { if len(match) == 2 { return match[1] != "0", true } + return false, false } @@ -788,6 +764,7 @@ func toolOutputStatus(tool, output string) (failed bool, known bool) { return true, true } } + return false, false } @@ -796,10 +773,12 @@ func exactBoolPointer(data json.RawMessage, key string) *bool { if json.Unmarshal(data, &fields) != nil { return nil } + value, ok := exactBool(fields, key) if !ok { return nil } + return &value } @@ -808,10 +787,12 @@ func exactBool(fields map[string]json.RawMessage, key string) (bool, bool) { if !ok || strings.TrimSpace(string(raw)) == "null" { return false, false } + var value bool if json.Unmarshal(raw, &value) != nil { return false, false } + return value, true } @@ -820,10 +801,12 @@ func exactInt(fields map[string]json.RawMessage, key string) (int, bool) { if !ok || strings.TrimSpace(string(raw)) == "null" { return 0, false } + var value int if json.Unmarshal(raw, &value) != nil { return 0, false } + return value, true } @@ -832,10 +815,12 @@ func exactString(fields map[string]json.RawMessage, key string) (string, bool) { if !ok || strings.TrimSpace(string(raw)) == "null" { return "", false } + var value string if json.Unmarshal(raw, &value) != nil { return "", false } + return value, true } @@ -868,7 +853,8 @@ func loadTitleIndex(path string) map[string]string { } titleIndexCache.mu.Lock() defer titleIndexCache.mu.Unlock() - if titleIndexCache.path == path && titleIndexCache.size == info.Size() && titleIndexCache.modTime.Equal(info.ModTime()) { + if titleIndexCache.path == path && titleIndexCache.size == info.Size() && + titleIndexCache.modTime.Equal(info.ModTime()) { return titleIndexCache.titles } f, err := os.Open(path) diff --git a/internal/adapter/codex/adapter_test.go b/internal/adapter/codex/adapter_test.go index 6324391..cb1f54e 100644 --- a/internal/adapter/codex/adapter_test.go +++ b/internal/adapter/codex/adapter_test.go @@ -5,6 +5,7 @@ import ( "math" "os" "path/filepath" + "strings" "testing" "github.com/cosmtrek/mindwalk/internal/judge" @@ -16,6 +17,7 @@ func TestParseCodexSession(t *testing.T) { if err := os.WriteFile(filepath.Join(root, "README.md"), []byte("# Demo\n"), 0o644); err != nil { t.Fatal(err) } + dir := t.TempDir() session := filepath.Join(dir, "rollout-2026-07-09T00-00-00-codex-demo.jsonl") index := filepath.Join(dir, "session_index.jsonl") @@ -24,7 +26,9 @@ func TestParseCodexSession(t *testing.T) { "thread_name": "Codex demo trace", "updated_at": "2026-07-09T00:00:09Z", }) - writeJSONL(t, session, + writeJSONL( + t, + session, map[string]any{ "timestamp": "2026-07-09T00:00:00Z", "type": "session_meta", @@ -61,23 +65,36 @@ func TestParseCodexSession(t *testing.T) { "workdir": filepath.ToSlash(root), }), output("2026-07-09T00:00:04Z", "call-read", "Chunk ID: read\nProcess exited with code 0\nOutput:\n# Demo\n"), - callRaw("2026-07-09T00:00:05Z", "fc-edit", "call-edit", "apply_patch", "*** Begin Patch\n*** Update File: README.md\n@@\n-# Demo\n+# Demo updated\n*** End Patch\n"), + callRaw( + "2026-07-09T00:00:05Z", + "fc-edit", + "call-edit", + "apply_patch", + "*** Begin Patch\n*** Update File: README.md\n@@\n-# Demo\n+# Demo updated\n*** End Patch\n", + ), output("2026-07-09T00:00:06Z", "call-edit", "Success. Updated the following files:\nM README.md\n"), call("2026-07-09T00:00:07Z", "fc-test", "call-test", "exec_command", map[string]any{ "cmd": "go test ./...", "workdir": filepath.ToSlash(root), }), - output("2026-07-09T00:00:08Z", "call-test", "Chunk ID: test\nProcess exited with code 1\nOutput:\nFAIL ./...\n"), + output( + "2026-07-09T00:00:08Z", + "call-test", + "Chunk ID: test\nProcess exited with code 1\nOutput:\nFAIL ./...\n", + ), ) adapter := Adapter{Dir: dir, IndexPath: index} + meta, err := adapter.Summarize(session) if err != nil { t.Fatal(err) } + if meta.ID != "codex-demo" || meta.Harness != "codex" || meta.Title != "Codex demo trace" { t.Fatalf("meta = %#v", meta) } + if meta.Model != "gpt-5.5" || meta.GitBranch != "main" || meta.EventCount != 3 { t.Fatalf("meta = %#v", meta) } @@ -86,30 +103,42 @@ func TestParseCodexSession(t *testing.T) { if err != nil { t.Fatal(err) } + if trace.Session.ID != "codex-demo" || trace.Session.Harness != "codex" || trace.Session.Commit != "abc123" { t.Fatalf("session = %#v", trace.Session) } + if trace.Session.Title != "Codex demo trace" || trace.Session.Model != "gpt-5.5" { t.Fatalf("session = %#v", trace.Session) } + if len(trace.Marks) != 1 || trace.Marks[0].Type != "user-message" || trace.Marks[0].Note != "inspect" { t.Fatalf("marks = %#v", trace.Marks) } + if len(trace.Events) != 3 { t.Fatalf("events = %d", len(trace.Events)) } - if got := trace.Events[0]; got.Tool != "exec_command" || got.Action != "read" || len(got.Targets) != 1 || got.Targets[0].Path != "README.md" || got.Targets[0].Touch != "read" { + + if got := trace.Events[0]; got.Tool != "exec_command" || got.Action != "read" || len(got.Targets) != 1 || + got.Targets[0].Path != "README.md" || + got.Targets[0].Touch != "read" { t.Fatalf("read event = %#v", got) } + if got := trace.Events[1]; got.Tool != "apply_patch" || got.Action != "edit" || got.Targets[0].Touch != "edit" { t.Fatalf("edit event = %#v", got) } + if got := trace.Events[2]; got.Action != "verify" || !got.IsError { t.Fatalf("verify event = %#v", got) } - if trace.Stats.Edited != 1 || trace.Stats.EventsBeforeFirstEdit != 1 || math.Abs(trace.Stats.ErrorRate-1.0/3.0) > 0.0001 { + + if trace.Stats.Edited != 1 || trace.Stats.EventsBeforeFirstEdit != 1 || + math.Abs(trace.Stats.ErrorRate-1.0/3.0) > 0.0001 { t.Fatalf("stats = %#v", trace.Stats) } + want := model.Observability{Reads: model.ObservabilityEstimated, Errors: model.ObservabilityEstimated} if trace.Stats.Observability != want { t.Fatalf("observability = %#v", trace.Stats.Observability) @@ -138,9 +167,11 @@ func TestParseCodexContextCompactedBecomesCompactionMark(t *testing.T) { if err != nil { t.Fatal(err) } + if len(trace.Marks) != 1 || trace.Marks[0].Type != "compaction" || trace.Marks[0].Seq != 1 { t.Fatalf("marks = %#v", trace.Marks) } + if trace.Stats.Compactions != 1 { t.Fatalf("compactions = %d", trace.Stats.Compactions) } @@ -168,9 +199,12 @@ func TestParseCodexSpawnAgentBecomesSubagentMark(t *testing.T) { if err != nil { t.Fatal(err) } - if len(trace.Marks) != 1 || trace.Marks[0].Type != "subagent" || trace.Marks[0].Seq != 1 || trace.Marks[0].Note != "spawn_agent" { + + if len(trace.Marks) != 1 || trace.Marks[0].Type != "subagent" || trace.Marks[0].Seq != 1 || + trace.Marks[0].Note != "spawn_agent" { t.Fatalf("marks = %#v", trace.Marks) } + if trace.Stats.Subagents != 1 { t.Fatalf("subagents = %d", trace.Stats.Subagents) } @@ -180,13 +214,17 @@ func TestParseCodexCustomCallsCanonicalizesOrderAndPatchResults(t *testing.T) { root := t.TempDir() readme := filepath.Join(root, "README.md") added := filepath.Join(root, "added.go") + if err := os.WriteFile(readme, []byte("# Demo\n"), 0o644); err != nil { t.Fatal(err) } + session := filepath.Join(t.TempDir(), "custom.jsonl") failed := false succeeded := true - writeJSONL(t, session, + writeJSONL( + t, + session, map[string]any{ "timestamp": "2026-07-10T00:00:00Z", "type": "session_meta", @@ -197,7 +235,13 @@ func TestParseCodexCustomCallsCanonicalizesOrderAndPatchResults(t *testing.T) { }, userMessage("2026-07-10T00:00:01Z", "before calls"), customCall("2026-07-10T00:00:02Z", "", "", "apply_patch", "*** Begin Patch\n"), - customCall("2026-07-10T00:00:03Z", "ctc-patch", "call-patch", "apply_patch", "*** Begin Patch\n*** Update File: README.md\n*** End Patch\n"), + customCall( + "2026-07-10T00:00:03Z", + "ctc-patch", + "call-patch", + "apply_patch", + "*** Begin Patch\n*** Update File: README.md\n*** End Patch\n", + ), userMessage("2026-07-10T00:00:04Z", "between call and output"), customOutput("2026-07-10T00:00:05Z", "call-late", "orphan output"), customCall("2026-07-10T00:00:06Z", "ctc-image", "call-image", "view_image", map[string]any{ @@ -228,10 +272,12 @@ func TestParseCodexCustomCallsCanonicalizesOrderAndPatchResults(t *testing.T) { ) a := Adapter{} + meta, err := a.Summarize(session) if err != nil { t.Fatal(err) } + if meta.EventCount != 3 { t.Fatalf("event count = %d, want 3", meta.EventCount) } @@ -240,28 +286,43 @@ func TestParseCodexCustomCallsCanonicalizesOrderAndPatchResults(t *testing.T) { if err != nil { t.Fatal(err) } + if trace.Session.EventCount != meta.EventCount || len(trace.Events) != 3 { - t.Fatalf("meta events = %d, trace session events = %d, events = %d", meta.EventCount, trace.Session.EventCount, len(trace.Events)) + t.Fatalf( + "meta events = %d, trace session events = %d, events = %d", + meta.EventCount, + trace.Session.EventCount, + len(trace.Events), + ) } + if len(trace.Marks) != 3 || trace.Marks[0].Seq != 0 || trace.Marks[1].Seq != 1 || trace.Marks[2].Seq != 3 { t.Fatalf("marks = %#v", trace.Marks) } + patchEvent := trace.Events[0] - if patchEvent.Tool != "apply_patch" || patchEvent.Action != "edit" || !patchEvent.IsError || patchEvent.ResultBytes != len("first patch output") { + if patchEvent.Tool != "apply_patch" || patchEvent.Action != "edit" || !patchEvent.IsError || + patchEvent.ResultBytes != len("first patch output") { t.Fatalf("patch event = %#v", patchEvent) } - if len(patchEvent.Targets) != 2 || patchEvent.Targets[0].Path != "README.md" || patchEvent.Targets[1].Path != "added.go" { + + if len(patchEvent.Targets) != 2 || patchEvent.Targets[0].Path != "README.md" || + patchEvent.Targets[1].Path != "added.go" { t.Fatalf("patch targets = %#v", patchEvent.Targets) } + imageEvent := trace.Events[1] if imageEvent.Tool != "view_image" || imageEvent.IsError || imageEvent.ResultBytes != len("first image output") { t.Fatalf("image event = %#v", imageEvent) } + if len(imageEvent.Targets) != 1 || imageEvent.Targets[0].Path != "README.md" { t.Fatalf("image targets = %#v", imageEvent.Targets) } + lateEvent := trace.Events[2] - if lateEvent.Tool != "exec_command" || lateEvent.Action != "verify" || lateEvent.ResultBytes != 0 || lateEvent.IsError { + if lateEvent.Tool != "exec_command" || lateEvent.Action != "verify" || lateEvent.ResultBytes != 0 || + lateEvent.IsError { t.Fatalf("late event = %#v", lateEvent) } } @@ -270,7 +331,9 @@ func TestPatchApplyEndSuccessOverridesTextualFailure(t *testing.T) { root := t.TempDir() session := filepath.Join(t.TempDir(), "patch-success.jsonl") succeeded := true - writeJSONL(t, session, + writeJSONL( + t, + session, map[string]any{ "timestamp": "2026-07-10T00:00:00Z", "type": "session_meta", @@ -279,7 +342,13 @@ func TestPatchApplyEndSuccessOverridesTextualFailure(t *testing.T) { "cwd": filepath.ToSlash(root), }, }, - customCall("2026-07-10T00:00:01Z", "ctc", "call-patch", "apply_patch", "*** Begin Patch\n*** Add File: added.go\n*** End Patch\n"), + customCall( + "2026-07-10T00:00:01Z", + "ctc", + "call-patch", + "apply_patch", + "*** Begin Patch\n*** Add File: added.go\n*** End Patch\n", + ), customOutput("2026-07-10T00:00:02Z", "call-patch", "Process exited with code 1"), patchApplyEnd("2026-07-10T00:00:03Z", "call-patch", &succeeded, map[string]string{ filepath.Join(root, "added.go"): "add", @@ -290,6 +359,7 @@ func TestPatchApplyEndSuccessOverridesTextualFailure(t *testing.T) { if err != nil { t.Fatal(err) } + if len(trace.Events) != 1 || trace.Events[0].IsError { t.Fatalf("events = %#v", trace.Events) } @@ -300,9 +370,15 @@ func TestParseCodexCustomExec(t *testing.T) { if err := os.WriteFile(filepath.Join(root, "README.md"), []byte("# Demo\n"), 0o644); err != nil { t.Fatal(err) } + session := filepath.Join(t.TempDir(), "custom-exec.jsonl") - script := `const r = await tools.exec_command({"cmd":"sed -n '1,20p' README.md","workdir":` + mustJSON(t, filepath.ToSlash(root)) + `}); text(r.output);` - writeJSONL(t, session, + script := `const r = await tools.exec_command({"cmd":"sed -n '1,20p' README.md","workdir":` + mustJSON( + t, + filepath.ToSlash(root), + ) + `}); text(r.output);` + writeJSONL( + t, + session, map[string]any{ "timestamp": "2026-07-10T00:00:00Z", "type": "session_meta", @@ -312,25 +388,36 @@ func TestParseCodexCustomExec(t *testing.T) { }, }, customCall("2026-07-10T00:00:01Z", "ctc", "call-exec", "exec", script), - customOutput("2026-07-10T00:00:02Z", "call-exec", []any{map[string]any{"type": "input_text", "text": "# Demo\n"}}), + customOutput( + "2026-07-10T00:00:02Z", + "call-exec", + []any{map[string]any{"type": "input_text", "text": "# Demo\n"}}, + ), ) a := Adapter{} + meta, err := a.Summarize(session) if err != nil { t.Fatal(err) } + trace, err := a.Parse(session) if err != nil { t.Fatal(err) } + if meta.EventCount != 1 || len(trace.Events) != 1 { t.Fatalf("meta=%d events=%d", meta.EventCount, len(trace.Events)) } + event := trace.Events[0] - if event.Tool != "exec" || event.Action != "read" || len(event.Targets) != 1 || event.Targets[0].Path != "README.md" || event.Targets[0].Touch != "read" { + if event.Tool != "exec" || event.Action != "read" || len(event.Targets) != 1 || + event.Targets[0].Path != "README.md" || + event.Targets[0].Touch != "read" { t.Fatalf("event = %#v", event) } + if want := "sed -n '1,20p' README.md -> 1 targets, 0 outside"; event.Summary != want { t.Fatalf("summary = %q, want %q", event.Summary, want) } @@ -338,10 +425,12 @@ func TestParseCodexCustomExec(t *testing.T) { func TestCallIDFallsBackToResponseID(t *testing.T) { root := t.TempDir() + path := filepath.Join(root, "README.md") if err := os.WriteFile(path, []byte("# Demo\n"), 0o644); err != nil { t.Fatal(err) } + session := filepath.Join(t.TempDir(), "fallback-id.jsonl") writeJSONL(t, session, map[string]any{ @@ -357,14 +446,17 @@ func TestCallIDFallsBackToResponseID(t *testing.T) { ) a := Adapter{} + meta, err := a.Summarize(session) if err != nil { t.Fatal(err) } + trace, err := a.Parse(session) if err != nil { t.Fatal(err) } + if meta.EventCount != 1 || len(trace.Events) != 1 || trace.Events[0].ResultBytes != 2 { t.Fatalf("meta=%#v events=%#v", meta, trace.Events) } @@ -372,13 +464,16 @@ func TestCallIDFallsBackToResponseID(t *testing.T) { func TestParseCodexJSRepl(t *testing.T) { root := t.TempDir() + path := filepath.Join(root, "packages", "db", "src", "index.ts") if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { t.Fatal(err) } + if err := os.WriteFile(path, []byte("export const db = {}\n"), 0o644); err != nil { t.Fatal(err) } + session := filepath.Join(t.TempDir(), "js-repl.jsonl") writeJSONL(t, session, map[string]any{ @@ -397,11 +492,14 @@ func TestParseCodexJSRepl(t *testing.T) { if err != nil { t.Fatal(err) } + if len(trace.Events) != 1 { t.Fatalf("events = %#v", trace.Events) } + event := trace.Events[0] - if event.Tool != "js_repl" || event.Action != "exec" || len(event.Targets) != 1 || event.Targets[0].Path != "packages/db/src/index.ts" { + if event.Tool != "js_repl" || event.Action != "exec" || len(event.Targets) != 1 || + event.Targets[0].Path != "packages/db/src/index.ts" { t.Fatalf("event = %#v", event) } } @@ -436,28 +534,55 @@ func TestCommandOutputStatus(t *testing.T) { for _, tt := range tests { failed, known := commandOutputStatus(tt.output) if failed != tt.wantFailed || known != tt.wantKnown { - t.Errorf("commandOutputStatus(%q) = (%v, %v), want (%v, %v)", tt.output, failed, known, tt.wantFailed, tt.wantKnown) + t.Errorf( + "commandOutputStatus(%q) = (%v, %v), want (%v, %v)", + tt.output, + failed, + known, + tt.wantFailed, + tt.wantKnown, + ) } } } func TestParseCodexScopesOutcomeInferenceAndMatchesExactKeys(t *testing.T) { session := filepath.Join(t.TempDir(), "outcome-scope.jsonl") - writeJSONL(t, session, + writeJSONL( + t, + session, map[string]any{ "timestamp": "2026-07-10T00:00:00Z", "type": "session_meta", "payload": map[string]any{"id": "outcome-scope", "cwd": filepath.ToSlash(t.TempDir())}, }, - customCall("2026-07-10T00:00:01Z", "ctc-script", "call-script", "view_image", map[string]any{"path": "image.png"}), + customCall( + "2026-07-10T00:00:01Z", + "ctc-script", + "call-script", + "view_image", + map[string]any{"path": "image.png"}, + ), customOutput("2026-07-10T00:00:02Z", "call-script", "Script completed\nWall time 0.1 seconds"), customCall("2026-07-10T00:00:03Z", "ctc-json", "call-json", "view_image", map[string]any{"path": "image.png"}), customOutput("2026-07-10T00:00:04Z", "call-json", `{"output":"failed","exit_code":1}`), call("2026-07-10T00:00:05Z", "fc-wrong", "call-wrong", "exec_command", map[string]any{"cmd": "false"}), output("2026-07-10T00:00:06Z", "call-wrong", `{"output":"failed","Exit_Code":1}`), - customCall("2026-07-10T00:00:07Z", "ctc-exact", "call-exact", "exec", `text(await tools.exec_command({cmd: "false"}))`), + customCall( + "2026-07-10T00:00:07Z", + "ctc-exact", + "call-exact", + "exec", + `text(await tools.exec_command({cmd: "false"}))`, + ), customOutput("2026-07-10T00:00:08Z", "call-exact", `{"output":"failed","exit_code":1}`), - customCall("2026-07-10T00:00:09Z", "ctc-patch", "call-patch", "apply_patch", "*** Begin Patch\n*** Add File: added.go\n*** End Patch\n"), + customCall( + "2026-07-10T00:00:09Z", + "ctc-patch", + "call-patch", + "apply_patch", + "*** Begin Patch\n*** Add File: added.go\n*** End Patch\n", + ), customOutput("2026-07-10T00:00:10Z", "call-patch", "plain output"), map[string]any{ "timestamp": "2026-07-10T00:00:11Z", @@ -468,7 +593,13 @@ func TestParseCodexScopesOutcomeInferenceAndMatchesExactKeys(t *testing.T) { "Success": true, }, }, - customCall("2026-07-10T00:00:12Z", "ctc-patch-fail", "call-patch-fail", "apply_patch", "*** Begin Patch\n*** Update File: missing.go\n*** End Patch\n"), + customCall( + "2026-07-10T00:00:12Z", + "ctc-patch-fail", + "call-patch-fail", + "apply_patch", + "*** Begin Patch\n*** Update File: missing.go\n*** End Patch\n", + ), customOutput("2026-07-10T00:00:13Z", "call-patch-fail", "apply_patch verification failed: missing.go"), ) @@ -476,17 +607,21 @@ func TestParseCodexScopesOutcomeInferenceAndMatchesExactKeys(t *testing.T) { if err != nil { t.Fatal(err) } + if len(trace.Events) != 6 { t.Fatalf("events = %#v", trace.Events) } + for _, index := range []int{0, 1, 2, 4} { if got := trace.Events[index]; got.OutcomeKnown || got.IsError { t.Fatalf("event %d promoted to known outcome: %#v", index, got) } } + if got := trace.Events[3]; !got.OutcomeKnown || !got.IsError { t.Fatalf("exact command envelope = %#v", got) } + if got := trace.Events[5]; !got.OutcomeKnown || !got.IsError { t.Fatalf("apply_patch failure = %#v", got) } @@ -514,6 +649,7 @@ func TestListSessionsSkipsNonCodexJSONL(t *testing.T) { if err != nil { t.Fatal(err) } + if len(sessions) != 1 || sessions[0].ID != "codex-list" { t.Fatalf("sessions = %#v", sessions) } @@ -525,6 +661,7 @@ func TestListSessionsSkipsCodexSubagents(t *testing.T) { legacySession := filepath.Join(dir, "legacy.jsonl") subagentSession := filepath.Join(dir, "subagent.jsonl") transitionalSubagentSession := filepath.Join(dir, "transitional-subagent.jsonl") + writeJSONL(t, mainSession, map[string]any{ "timestamp": "2026-07-10T00:00:00Z", @@ -598,37 +735,46 @@ func TestListSessionsSkipsCodexSubagents(t *testing.T) { }) adapter := Adapter{Dir: dir} + mainMeta, err := adapter.Summarize(mainSession) if err != nil || mainMeta.Auxiliary { t.Fatalf("main meta = %#v, err = %v", mainMeta, err) } + subagentMeta, err := adapter.Summarize(subagentSession) if err != nil || !subagentMeta.Auxiliary || subagentMeta.ID != "child-thread" || subagentMeta.EventCount != 1 { t.Fatalf("subagent meta = %#v, err = %v", subagentMeta, err) } + subagentTrace, err := adapter.Parse(subagentSession) if err != nil { t.Fatal(err) } + if subagentTrace.Session.ID != subagentMeta.ID || subagentTrace.Session.Title != subagentMeta.Title || subagentTrace.Session.EndedAt != subagentMeta.EndedAt || subagentTrace.Session.EventCount != subagentMeta.EventCount { t.Fatalf("subagent summary/trace mismatch: meta=%#v trace=%#v", subagentMeta, subagentTrace.Session) } + transitionalMeta, err := adapter.Summarize(transitionalSubagentSession) if err != nil || !transitionalMeta.Auxiliary { t.Fatalf("transitional subagent meta = %#v, err = %v", transitionalMeta, err) } + sessions, err := adapter.ListSessions() if err != nil { t.Fatal(err) } + if len(sessions) != 2 { t.Fatalf("sessions = %#v", sessions) } + visible := map[string]string{} for _, session := range sessions { visible[session.ID] = session.Path } + if visible["main-thread"] != mainSession || visible["legacy-main"] != legacySession { t.Fatalf("visible sessions = %#v", visible) } @@ -660,9 +806,11 @@ func TestSummarizePopulatesSubagentMetadata(t *testing.T) { if err != nil { t.Fatal(err) } + if !meta.Auxiliary || meta.Agent == nil { t.Fatalf("meta = %#v", meta) } + want := model.AgentSessionMeta{ SourceID: "child-thread", RootSessionID: "root-thread", @@ -683,8 +831,16 @@ func TestSessionMetaPayloadDetectsSubagentFormats(t *testing.T) { want bool }{ {name: "thread source", payload: sessionMetaPayload{ThreadSource: "subagent"}, want: true}, - {name: "legacy object", payload: sessionMetaPayload{Source: json.RawMessage(`{"subagent":{"thread_spawn":{}}}`)}, want: true}, - {name: "legacy scalar", payload: sessionMetaPayload{Source: json.RawMessage(`{"subagent":"memory_consolidation"}`)}, want: true}, + { + name: "legacy object", + payload: sessionMetaPayload{Source: json.RawMessage(`{"subagent":{"thread_spawn":{}}}`)}, + want: true, + }, + { + name: "legacy scalar", + payload: sessionMetaPayload{Source: json.RawMessage(`{"subagent":"memory_consolidation"}`)}, + want: true, + }, {name: "null subagent", payload: sessionMetaPayload{Source: json.RawMessage(`{"subagent":null}`)}, want: false}, {name: "top level source", payload: sessionMetaPayload{Source: json.RawMessage(`"vscode"`)}, want: false}, {name: "unknown object", payload: sessionMetaPayload{Source: json.RawMessage(`{"other":"user"}`)}, want: false}, @@ -701,6 +857,7 @@ func TestSessionMetaPayloadDetectsSubagentFormats(t *testing.T) { func call(ts, id, callID, name string, args map[string]any) map[string]any { b, _ := json.Marshal(args) + return callRaw(ts, id, callID, name, string(b)) } @@ -773,6 +930,7 @@ func patchApplyEnd(ts, callID string, success *bool, changes map[string]string) for path, changeType := range changes { payloadChanges[path] = map[string]any{"type": changeType} } + return map[string]any{ "timestamp": ts, "type": "event_msg", @@ -787,24 +945,30 @@ func patchApplyEnd(ts, callID string, success *bool, changes map[string]string) func mustJSON(t *testing.T, value any) string { t.Helper() + encoded, err := json.Marshal(value) if err != nil { t.Fatal(err) } + return string(encoded) } func writeJSONL(t *testing.T, path string, values ...any) { t.Helper() - content := "" + + var content strings.Builder + for _, value := range values { b, err := json.Marshal(value) if err != nil { t.Fatal(err) } - content += string(b) + "\n" + + content.WriteString(string(b) + "\n") } - if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + + if err := os.WriteFile(path, []byte(content.String()), 0o644); err != nil { t.Fatal(err) } } @@ -814,6 +978,7 @@ func TestSummarizeMarksJudgeWorkdirAuxiliary(t *testing.T) { if workdir == "" { t.Skip("no home directory available") } + dir := t.TempDir() session := filepath.Join(dir, "rollout-2026-07-14T00-00-00-judge-run.jsonl") writeJSONL(t, session, @@ -828,18 +993,23 @@ func TestSummarizeMarksJudgeWorkdirAuxiliary(t *testing.T) { }, }, ) + adapter := Adapter{Dir: dir} + meta, err := adapter.Summarize(session) if err != nil { t.Fatal(err) } + if !meta.Auxiliary { t.Fatalf("session recorded in judge workdir %s should be auxiliary", workdir) } + metas, err := adapter.ListSessions() if err != nil { t.Fatal(err) } + for _, m := range metas { if m.ID == "judge-run" { t.Fatal("judge session leaked into ListSessions") @@ -865,17 +1035,25 @@ func TestParseSkipsInjectedUserMessages(t *testing.T) { map[string]any{ "timestamp": "2026-07-14T00:00:00Z", "type": "session_meta", - "payload": map[string]any{"id": "injected", "session_id": "injected", "timestamp": "2026-07-14T00:00:00Z", "cwd": "/repo"}, + "payload": map[string]any{ + "id": "injected", + "session_id": "injected", + "timestamp": "2026-07-14T00:00:00Z", + "cwd": "/repo", + }, }, userMessage("# AGENTS.md instructions for /repo\n\nproject rules"), userMessage("shell: zsh"), userMessage("the real task"), ) + trace, err := (Adapter{Dir: dir}).Parse(session) if err != nil { t.Fatal(err) } + var notes []string + for _, mark := range trace.Marks { if mark.Type == "user-message" { notes = append(notes, mark.Note) @@ -886,6 +1064,7 @@ func TestParseSkipsInjectedUserMessages(t *testing.T) { if len(notes) != 1 || notes[0] != "the real task" { t.Fatalf("user-message notes = %#v", notes) } + if trace.Stats.UserTurns != 1 { t.Fatalf("userTurns = %d, want 1", trace.Stats.UserTurns) } @@ -895,6 +1074,7 @@ func TestParseSkipsInjectedUserMessages(t *testing.T) { if err != nil { t.Fatal(err) } + if meta.UserTurns != 1 { t.Fatalf("summarized userTurns = %d, want 1", meta.UserTurns) } @@ -905,15 +1085,18 @@ func TestParseRejectsRolelessMessageLines(t *testing.T) { // under "message" with no top-level role; those files are not Codex's. dir := t.TempDir() path := filepath.Join(dir, "session.jsonl") + lines := `{"type":"session","version":3,"id":"s1","timestamp":"2026-07-21T14:34:07.238Z","cwd":"/tmp"} {"type":"message","id":"e1","parentId":null,"timestamp":"2026-07-21T14:34:22.963Z","message":{"role":"user","content":"hi"}} ` if err := os.WriteFile(path, []byte(lines), 0o644); err != nil { t.Fatal(err) } + if _, err := (Adapter{}).Parse(path); err == nil { t.Fatal("Parse claimed a roleless message file") } + if _, err := (Adapter{}).Summarize(path); err == nil { t.Fatal("Summarize claimed a roleless message file") } diff --git a/internal/adapter/codex/agents.go b/internal/adapter/codex/agents.go index cc532e6..a42e685 100644 --- a/internal/adapter/codex/agents.go +++ b/internal/adapter/codex/agents.go @@ -2,7 +2,7 @@ package codex import ( "encoding/json" - "os" + "fmt" "path/filepath" "sort" "strings" @@ -11,46 +11,35 @@ import ( "github.com/cosmtrek/mindwalk/internal/model" ) -type agentLaunch struct { - callID string - seq int - arguments map[string]any - output string - outputObserved bool -} - -type agentLaunchOutput struct { - AgentID string `json:"agent_id"` - Nickname string `json:"nickname"` - TaskName string `json:"task_name"` -} - -type codexGraphActor struct { - session model.SessionMeta - nodeID string - depth int - sourceID string -} +type codexGraphActor = adapter.GraphActor func (a Adapter) AgentGraphInputs(root model.SessionMeta, catalog []model.SessionMeta) ([]string, error) { paths := map[string]bool{root.Path: true} childrenByParent := make(map[string][]model.SessionMeta) + for _, session := range catalog { if session.Agent == nil || session.Agent.ParentSessionID == "" || session.Harness != a.Harness() { continue } - childrenByParent[session.Agent.ParentSessionID] = append(childrenByParent[session.Agent.ParentSessionID], session) + + childrenByParent[session.Agent.ParentSessionID] = append( + childrenByParent[session.Agent.ParentSessionID], + session, + ) } visitedSessions := map[string]bool{root.Key: true} + queue := []string{root.ID} for len(queue) > 0 { parentID := queue[0] queue = queue[1:] + for _, child := range childrenByParent[parentID] { if visitedSessions[child.Key] { continue } + visitedSessions[child.Key] = true paths[child.Path] = true queue = append(queue, codexSessionSourceID(child)) @@ -63,7 +52,9 @@ func (a Adapter) AgentGraphInputs(root model.SessionMeta, catalog []model.Sessio inputs = append(inputs, path) } } + sort.Strings(inputs) + return inputs, nil } @@ -88,16 +79,21 @@ func (a Adapter) BuildAgentGraph(root model.SessionMeta, catalog []model.Session childrenByParent := make(map[string][]model.SessionMeta) ambiguousRootID := false + for _, session := range catalog { - if session.Key != root.Key && session.Harness == a.Harness() && !session.Auxiliary && session.Agent == nil && session.ID == root.ID { + if session.Key != root.Key && session.Harness == a.Harness() && !session.Auxiliary && session.Agent == nil && + session.ID == root.ID { ambiguousRootID = true } + if session.Agent == nil || session.Agent.ParentSessionID == "" { continue } + parentID := session.Agent.ParentSessionID childrenByParent[parentID] = append(childrenByParent[parentID], session) } + for parentID := range childrenByParent { sort.Slice(childrenByParent[parentID], func(i, j int) bool { return childrenByParent[parentID][i].Key < childrenByParent[parentID][j].Key @@ -106,66 +102,80 @@ func (a Adapter) BuildAgentGraph(root model.SessionMeta, catalog []model.Session visitedSessions := map[string]bool{root.Key: true} addedNodes := map[string]bool{mainID: true} - queue := []codexGraphActor{{session: root, nodeID: mainID, sourceID: root.ID}} + + queue := []codexGraphActor{{Session: root, NodeID: mainID, SourceID: root.ID}} for len(queue) > 0 { actor := queue[0] queue = queue[1:] - launches, err := readAgentLaunches(actor.session.Path) + launches, err := readAgentLaunches(actor.Session.Path) if err != nil { - return nil, err + return nil, fmt.Errorf("read agent launches from %s: %w", actor.Session.Path, err) } - children := childrenByParent[actor.sourceID] + + children := childrenByParent[actor.SourceID] linkedChildren := make(map[string]bool) + for _, launch := range launches { - launchOutput, hasIdentity := parseAgentLaunchOutput(launch.output) + launchOutput, hasIdentity := parseAgentLaunchOutput(launch.Output) if hasIdentity && launchOutput.AgentID != "" { var child *model.SessionMeta + for i := range children { candidate := &children[i] if candidate.Agent.SourceID == launchOutput.AgentID && !visitedSessions[candidate.Key] { child = candidate + break } } + node := exactCodexAgentNode(a.Harness(), root.Key, actor, launch, launchOutput, child) if !addedNodes[node.ID] { graph.Agents = append(graph.Agents, node) addedNodes[node.ID] = true } + if child != nil { linkedChildren[child.Key] = true visitedSessions[child.Key] = true queue = append(queue, codexGraphActor{ - session: *child, - nodeID: node.ID, - depth: node.Depth, - sourceID: codexSessionSourceID(*child), + Session: *child, + NodeID: node.ID, + Depth: node.Depth, + SourceID: codexSessionSourceID(*child), }) } + continue } + if hasIdentity && launchOutput.TaskName != "" { var child *model.SessionMeta + for i := range children { candidate := &children[i] if candidate.Agent.AgentPath == launchOutput.TaskName && !visitedSessions[candidate.Key] { child = candidate + break } } + node := legacyCodexAgentNode(a.Harness(), root.Key, actor, launch, launchOutput, child) if !addedNodes[node.ID] { graph.Agents = append(graph.Agents, node) addedNodes[node.ID] = true } + if child != nil { linkedChildren[child.Key] = true visitedSessions[child.Key] = true queue = append(queue, codexGraphActor{ - session: *child, nodeID: node.ID, depth: node.Depth, sourceID: codexSessionSourceID(*child), + Session: *child, NodeID: node.ID, Depth: node.Depth, SourceID: codexSessionSourceID(*child), }) } + continue } @@ -181,37 +191,41 @@ func (a Adapter) BuildAgentGraph(root model.SessionMeta, catalog []model.Session if linkedChildren[child.Key] || visitedSessions[child.Key] { continue } - if actor.session.Key == root.Key && ambiguousRootID { + + if actor.Session.Key == root.Key && ambiguousRootID { continue } + node := derivedCodexAgentNode(a.Harness(), root.Key, actor, child) if addedNodes[node.ID] { continue } + graph.Agents = append(graph.Agents, node) addedNodes[node.ID] = true visitedSessions[child.Key] = true queue = append(queue, codexGraphActor{ - session: child, - nodeID: node.ID, - depth: node.Depth, - sourceID: codexSessionSourceID(child), + Session: child, + NodeID: node.ID, + Depth: node.Depth, + SourceID: codexSessionSourceID(child), }) } } graph.Agents = adapter.OrderAgentNodesPreorder(graph.Agents) + return graph, nil } -func readAgentLaunches(path string) ([]agentLaunch, error) { - f, err := os.Open(path) +func readAgentLaunches(path string) ([]adapter.AgentLaunch, error) { + f, closeFile, err := adapter.OpenFile(path) if err != nil { return nil, err } - defer f.Close() + defer closeFile() - launches := []agentLaunch{} + launches := []adapter.AgentLaunch{} launchByCallID := make(map[string]int) seenCalls := make(map[string]bool) seq := 0 @@ -220,147 +234,174 @@ func readAgentLaunches(path string) ([]agentLaunch, error) { if json.Unmarshal(data, &line) != nil || line.Type != "response_item" { return } + var payload responseItemPayload if json.Unmarshal(line.Payload, &payload) != nil { return } + if call, _, ok := decodeCall(payload, line.Timestamp); ok { if seenCalls[call.ID] { return } + seenCalls[call.ID] = true if call.Name == "spawn_agent" { launchByCallID[call.ID] = len(launches) - launches = append(launches, agentLaunch{ - callID: call.ID, - seq: seq, - arguments: call.Input, + launches = append(launches, adapter.AgentLaunch{ + CallID: call.ID, + Seq: seq, + Arguments: call.Input, }) } + seq++ + return } + callID, result, ok := decodeOutput(payload) if !ok { return } + index, ok := launchByCallID[callID] - if !ok || launches[index].outputObserved { + if !ok || launches[index].OutputObserved { return } - launches[index].output = result.Content - launches[index].outputObserved = true + + launches[index].Output = result.Content + launches[index].OutputObserved = true }) + return launches, err } -func exactCodexAgentNode(harness, rootKey string, actor codexGraphActor, launch agentLaunch, output agentLaunchOutput, child *model.SessionMeta) model.AgentNode { - seq := launch.seq +func exactCodexAgentNode( + harness, rootKey string, + actor codexGraphActor, + launch adapter.AgentLaunch, + output adapter.AgentLaunchOutput, + child *model.SessionMeta, +) model.AgentNode { + seq := launch.Seq + node := model.AgentNode{ ID: adapter.AgentNodeID(harness, rootKey, "codex-agent:"+output.AgentID), - ParentID: actor.nodeID, - Depth: actor.depth + 1, + ParentID: actor.NodeID, + Depth: actor.Depth + 1, Kind: model.AgentKindSubagent, Label: output.Nickname, - Role: agentArgument(launch.arguments, "agent_type"), - InstructionPreview: adapter.AgentInstructionPreview(agentArgument(launch.arguments, "message")), + Role: agentArgument(launch.Arguments, "agent_type"), + InstructionPreview: adapter.AgentInstructionPreview(agentArgument(launch.Arguments, "message")), LaunchSeq: &seq, - LaunchCallID: launch.callID, + LaunchCallID: launch.CallID, Status: model.AgentStatusLaunched, TraceAvailability: model.TraceAvailabilityMissing, LinkQuality: model.AgentLinkQualityExact, LinkMethod: model.AgentLinkMethodCodexAgentID, } if child != nil { - node.Depth = codexChildDepth(*child, actor.depth) + node.Depth = codexChildDepth(*child, actor.Depth) + node.Label = child.Agent.Label if child.Agent.Role != "" { node.Role = child.Agent.Role } + node.TraceAvailability = model.TraceAvailabilityAvailable node.TraceSessionKey = child.Key node.TraceEventCount = child.EventCount } - if node.Label == "" { - node.Label = output.Nickname - } - if node.Label == "" { - node.Label = "Subagent" - } + + adapter.ApplyLaunchNickname(&node, output) + return node } -func unlinkedCodexLaunchNode(harness, rootKey string, actor codexGraphActor, launch agentLaunch) model.AgentNode { - seq := launch.seq - status := model.AgentStatusUnknown - trimmedOutput := strings.TrimSpace(launch.output) - if launch.outputObserved && trimmedOutput != "" && !json.Valid([]byte(trimmedOutput)) { - status = model.AgentStatusFailed - } +func unlinkedCodexLaunchNode( + harness, rootKey string, + actor codexGraphActor, + launch adapter.AgentLaunch, +) model.AgentNode { + seq := launch.Seq + return model.AgentNode{ - ID: adapter.AgentNodeID(harness, rootKey, "launch:"+actor.nodeID+":"+launch.callID), - ParentID: actor.nodeID, - Depth: actor.depth + 1, + ID: adapter.AgentNodeID(harness, rootKey, "launch:"+actor.NodeID+":"+launch.CallID), + ParentID: actor.NodeID, + Depth: actor.Depth + 1, Kind: model.AgentKindSubagent, - Label: "Subagent", - Role: agentArgument(launch.arguments, "agent_type"), - InstructionPreview: adapter.AgentInstructionPreview(agentArgument(launch.arguments, "message")), + Label: adapter.SubagentLabel, + Role: agentArgument(launch.Arguments, "agent_type"), + InstructionPreview: adapter.AgentInstructionPreview(agentArgument(launch.Arguments, "message")), LaunchSeq: &seq, - LaunchCallID: launch.callID, - Status: status, + LaunchCallID: launch.CallID, + Status: adapter.UnlinkedLaunchStatus(launch), TraceAvailability: model.TraceAvailabilityUnavailable, LinkQuality: model.AgentLinkQualityUnavailable, LinkMethod: model.AgentLinkMethodUnavailable, } } -func legacyCodexAgentNode(harness, rootKey string, actor codexGraphActor, launch agentLaunch, output agentLaunchOutput, child *model.SessionMeta) model.AgentNode { - seq := launch.seq +func legacyCodexAgentNode( + harness, rootKey string, + actor codexGraphActor, + launch adapter.AgentLaunch, + output adapter.AgentLaunchOutput, + child *model.SessionMeta, +) model.AgentNode { + seq := launch.Seq + label := filepath.Base(output.TaskName) if label == "." || label == "/" || label == "" { - label = "Subagent" + label = adapter.SubagentLabel } + node := model.AgentNode{ ID: adapter.AgentNodeID(harness, rootKey, "codex-task:"+output.TaskName), - ParentID: actor.nodeID, - Depth: actor.depth + 1, + ParentID: actor.NodeID, + Depth: actor.Depth + 1, Kind: model.AgentKindSubagent, Label: label, - Role: agentArgument(launch.arguments, "agent_type"), - InstructionPreview: adapter.AgentInstructionPreview(agentArgument(launch.arguments, "message")), + Role: agentArgument(launch.Arguments, "agent_type"), + InstructionPreview: adapter.AgentInstructionPreview(agentArgument(launch.Arguments, "message")), LaunchSeq: &seq, - LaunchCallID: launch.callID, + LaunchCallID: launch.CallID, Status: model.AgentStatusLaunched, TraceAvailability: model.TraceAvailabilityMissing, LinkQuality: model.AgentLinkQualityUnavailable, LinkMethod: model.AgentLinkMethodUnavailable, } if child != nil { - node.Depth = codexChildDepth(*child, actor.depth) + node.Depth = codexChildDepth(*child, actor.Depth) if child.Agent.Label != "" { node.Label = child.Agent.Label } + if child.Agent.Role != "" { node.Role = child.Agent.Role } + node.TraceAvailability = model.TraceAvailabilityAvailable node.TraceSessionKey = child.Key node.TraceEventCount = child.EventCount node.LinkQuality = model.AgentLinkQualityDerived node.LinkMethod = model.AgentLinkMethodCodexParentThreadID } + return node } func derivedCodexAgentNode(harness, rootKey string, actor codexGraphActor, child model.SessionMeta) model.AgentNode { label := child.Agent.Label if label == "" { - label = "Subagent" + label = adapter.SubagentLabel } + return model.AgentNode{ ID: adapter.AgentNodeID(harness, rootKey, "session:"+child.Key), - ParentID: actor.nodeID, - Depth: codexChildDepth(child, actor.depth), + ParentID: actor.NodeID, + Depth: codexChildDepth(child, actor.Depth), Kind: model.AgentKindSubagent, Label: label, Role: child.Agent.Role, @@ -373,16 +414,19 @@ func derivedCodexAgentNode(harness, rootKey string, actor codexGraphActor, child } } -func parseAgentLaunchOutput(output string) (agentLaunchOutput, bool) { - var parsed agentLaunchOutput - if json.Unmarshal([]byte(strings.TrimSpace(output)), &parsed) != nil || (parsed.AgentID == "" && parsed.TaskName == "") { - return agentLaunchOutput{}, false +func parseAgentLaunchOutput(output string) (adapter.AgentLaunchOutput, bool) { + var parsed adapter.AgentLaunchOutput + if json.Unmarshal([]byte(strings.TrimSpace(output)), &parsed) != nil || + (parsed.AgentID == "" && parsed.TaskName == "") { + return adapter.AgentLaunchOutput{}, false } + return parsed, true } func agentArgument(arguments map[string]any, key string) string { value, _ := arguments[key].(string) + return value } @@ -390,6 +434,7 @@ func codexChildDepth(child model.SessionMeta, parentDepth int) int { if child.Agent.Depth > 0 { return child.Agent.Depth } + return parentDepth + 1 } @@ -397,5 +442,6 @@ func codexSessionSourceID(session model.SessionMeta) string { if session.Agent != nil && session.Agent.SourceID != "" { return session.Agent.SourceID } + return session.ID } diff --git a/internal/adapter/codex/agents_test.go b/internal/adapter/codex/agents_test.go index 1a99105..5e7a085 100644 --- a/internal/adapter/codex/agents_test.go +++ b/internal/adapter/codex/agents_test.go @@ -58,6 +58,7 @@ func TestCodexAgentGraphFixtures(t *testing.T) { }, }) seq := 1 + return fixture, []model.AgentNode{ mainAgentNode(fixture.root), { @@ -91,10 +92,14 @@ func TestCodexAgentGraphFixtures(t *testing.T) { output("", "call-failed", "fork requires a previous turn"), }) seq := 0 + return fixture, []model.AgentNode{ mainAgentNode(fixture.root), { - ID: codexAgentID(fixture.root, "launch:"+codexMainID(fixture.root)+":call-failed"), + ID: codexAgentID( + fixture.root, + "launch:"+codexMainID(fixture.root)+":call-failed", + ), ParentID: codexMainID(fixture.root), Depth: 1, Kind: model.AgentKindSubagent, @@ -122,6 +127,7 @@ func TestCodexAgentGraphFixtures(t *testing.T) { output("", "call-missing", `{"agent_id":"agent-missing","nickname":"Nova"}`), }) seq := 0 + return fixture, []model.AgentNode{ mainAgentNode(fixture.root), { @@ -170,6 +176,7 @@ func TestCodexAgentGraphFixtures(t *testing.T) { ) rootSeq, nestedSeq := 0, 1 childID := codexAgentID(fixture.root, "codex-agent:agent-child") + return fixture, []model.AgentNode{ mainAgentNode(fixture.root), { @@ -217,6 +224,7 @@ func TestCodexAgentGraphFixtures(t *testing.T) { label: "Orphan", role: "explorer", }) + return fixture, []model.AgentNode{ mainAgentNode(fixture.root), { @@ -250,6 +258,7 @@ func TestCodexAgentGraphFixtures(t *testing.T) { label: "Valid", }) seq := 0 + return fixture, []model.AgentNode{ mainAgentNode(fixture.root), { @@ -284,6 +293,7 @@ func TestCodexAgentGraphFixtures(t *testing.T) { label: "Zero", }) seq := 0 + return fixture, []model.AgentNode{ mainAgentNode(fixture.root), { @@ -315,10 +325,14 @@ func TestCodexAgentGraphFixtures(t *testing.T) { }), }) seq := 0 + return fixture, []model.AgentNode{ mainAgentNode(fixture.root), { - ID: codexAgentID(fixture.root, "launch:"+codexMainID(fixture.root)+":call-unknown"), + ID: codexAgentID( + fixture.root, + "launch:"+codexMainID(fixture.root)+":call-unknown", + ), ParentID: codexMainID(fixture.root), Depth: 1, Kind: model.AgentKindSubagent, @@ -351,10 +365,18 @@ func TestCodexAgentGraphFixtures(t *testing.T) { codexAgentChildFixture{id: "agent-zulu", parentID: "root-session", depth: 1, label: "Zulu"}, ) zuluSeq, alphaSeq := 0, 2 + return fixture, []model.AgentNode{ mainAgentNode(fixture.root), availableCodexLaunch(fixture.root, fixture.catalog[2], "agent-zulu", "call-zulu", "zulu", zuluSeq), - availableCodexLaunch(fixture.root, fixture.catalog[1], "agent-alpha", "call-alpha", "alpha", alphaSeq), + availableCodexLaunch( + fixture.root, + fixture.catalog[1], + "agent-alpha", + "call-alpha", + "alpha", + alphaSeq, + ), { ID: codexAgentID(fixture.root, "session:"+fixture.catalog[0].Key), ParentID: codexMainID(fixture.root), @@ -375,20 +397,25 @@ func TestCodexAgentGraphFixtures(t *testing.T) { for _, test := range tests { t.Run(test.name, func(t *testing.T) { fixture, want := test.setup(t) + first, err := fixture.adapter.BuildAgentGraph(fixture.root, fixture.catalog) if err != nil { t.Fatal(err) } + second, err := fixture.adapter.BuildAgentGraph(fixture.root, fixture.catalog) if err != nil { t.Fatal(err) } + if !reflect.DeepEqual(first, second) { t.Fatalf("successive builds differ:\nfirst: %#v\nsecond: %#v", first, second) } + if first.Version != model.AgentGraphVersion || first.RootSessionKey != fixture.root.Key { t.Fatalf("graph header = %#v", first) } + if !reflect.DeepEqual(first.Agents, want) { t.Fatalf("agents:\n got: %#v\nwant: %#v", first.Agents, want) } @@ -414,9 +441,11 @@ func TestCodexAgentGraphLegacyTaskNameMergesDerivedChild(t *testing.T) { if err != nil { t.Fatal(err) } + if len(graph.Agents) != 2 { t.Fatalf("agents = %#v, want Main plus one merged legacy child", graph.Agents) } + node := graph.Agents[1] if node.Label != "Legacy Reviewer" || node.LaunchCallID != "call-legacy" || node.Status != model.AgentStatusLaunched || node.TraceAvailability != model.TraceAvailabilityAvailable || @@ -435,6 +464,7 @@ func TestCodexAgentGraphUnknownJSONObjectIsNotFailure(t *testing.T) { if err != nil { t.Fatal(err) } + if len(graph.Agents) != 2 || graph.Agents[1].Status != model.AgentStatusUnknown { t.Fatalf("unknown output node = %#v", graph.Agents) } @@ -462,10 +492,12 @@ func TestCodexAgentGraphUsesStablePreorder(t *testing.T) { if err != nil { t.Fatal(err) } + labels := make([]string, len(graph.Agents)) for i, node := range graph.Agents { labels[i] = node.Label } + want := []string{"Main", "A", "A1", "B"} if !reflect.DeepEqual(labels, want) { t.Fatalf("agent order = %v, want %v", labels, want) @@ -483,8 +515,10 @@ func TestCodexAgentGraphInputsIncludeNestedChildren(t *testing.T) { if err != nil { t.Fatal(err) } + want := []string{fixture.root.Path, fixture.catalog[0].Path, fixture.catalog[1].Path} sort.Strings(want) + if !reflect.DeepEqual(inputs, want) { t.Fatalf("graph inputs = %v, want %v", inputs, want) } @@ -496,21 +530,26 @@ func newCodexAgentFixture(t *testing.T, rootLines []any, children ...codexAgentC a := Adapter{Dir: dir} rootPath := filepath.Join(dir, "root.jsonl") writeAgentJSONL(t, rootPath, append([]any{codexRootSessionMeta()}, rootLines...)...) + root, err := a.Summarize(rootPath) if err != nil { t.Fatal(err) } + catalog := make([]model.SessionMeta, 0, len(children)) for _, child := range children { path := filepath.Join(dir, child.id+".jsonl") lines := append([]any{codexChildSessionMeta(child)}, child.lines...) writeAgentJSONL(t, path, lines...) + meta, err := a.Summarize(path) if err != nil { t.Fatal(err) } + catalog = append(catalog, meta) } + return codexAgentFixture{adapter: a, root: root, catalog: catalog} } @@ -548,20 +587,26 @@ func codexChildSessionMeta(child codexAgentChildFixture) map[string]any { func writeAgentJSONL(t *testing.T, path string, values ...any) { t.Helper() + var content []byte + for _, value := range values { if raw, ok := value.(rawAgentJSONL); ok { content = append(content, raw...) content = append(content, '\n') + continue } + line, err := json.Marshal(value) if err != nil { t.Fatal(err) } + content = append(content, line...) content = append(content, '\n') } + if err := os.WriteFile(path, content, 0o644); err != nil { t.Fatal(err) } @@ -582,7 +627,12 @@ func mainAgentNode(root model.SessionMeta) model.AgentNode { } } -func availableCodexLaunch(root model.SessionMeta, child model.SessionMeta, agentID, callID, message string, seq int) model.AgentNode { +func availableCodexLaunch( + root model.SessionMeta, + child model.SessionMeta, + agentID, callID, message string, + seq int, +) model.AgentNode { return model.AgentNode{ ID: codexAgentID(root, "codex-agent:"+agentID), ParentID: codexMainID(root), diff --git a/internal/adapter/codex/diagnostics.go b/internal/adapter/codex/diagnostics.go new file mode 100644 index 0000000..7cd5b01 --- /dev/null +++ b/internal/adapter/codex/diagnostics.go @@ -0,0 +1,31 @@ +package codex + +import ( + "fmt" + "os" + + "github.com/cosmtrek/mindwalk/internal/adapter" +) + +func (a Adapter) Diagnostics() []adapter.DiagnosticCheck { + checks := adapter.FilesystemDiagnostics(a.SessionDir(), ".jsonl") + + idx := a.indexPath() + if idx != "" { + if _, err := os.Stat(idx); err != nil { + checks = append(checks, adapter.DiagnosticCheck{ + Name: "session-index", + Status: "warn", + Detail: fmt.Sprintf("index file %s not found", idx), + }) + } else { + checks = append(checks, adapter.DiagnosticCheck{ + Name: "session-index", + Status: "ok", + Detail: idx, + }) + } + } + + return checks +} diff --git a/internal/adapter/codex/summary_inputs_test.go b/internal/adapter/codex/summary_inputs_test.go index 5cce408..b7adfff 100644 --- a/internal/adapter/codex/summary_inputs_test.go +++ b/internal/adapter/codex/summary_inputs_test.go @@ -8,6 +8,7 @@ import ( func TestSummaryInputsDeclaresTitleIndex(t *testing.T) { a := Adapter{Dir: filepath.Join("root", "sessions")} inputs := a.SummaryInputs("any.jsonl") + want := filepath.Join("root", "session_index.jsonl") if len(inputs) == 0 || inputs[0] != want { t.Fatalf("SummaryInputs = %v, want first candidate %s", inputs, want) diff --git a/internal/adapter/fuzz_test.go b/internal/adapter/fuzz_test.go new file mode 100644 index 0000000..f593bee --- /dev/null +++ b/internal/adapter/fuzz_test.go @@ -0,0 +1,24 @@ +package adapter + +import "testing" + +// FuzzGitDiffTargets verifies that arbitrary text input never panics +// or duplicates targets in the diff parser. +func FuzzGitDiffTargets(f *testing.F) { + f.Add("diff --git a/foo.go b/foo.go\n+++ b/foo.go\n") + f.Add("") + f.Add("--- a/old\n+++ b/new\n@@ -1,3 +1,4 @@\n") + f.Add("no diff content at all") + f.Fuzz(func(t *testing.T, text string) { + targets := gitDiffTargets(text) + + seen := map[string]bool{} + for _, target := range targets { + if seen[target.path] { + t.Fatalf("duplicate path %q in gitDiffTargets output", target.path) + } + + seen[target.path] = true + } + }) +} diff --git a/internal/adapter/helpers_test.go b/internal/adapter/helpers_test.go new file mode 100644 index 0000000..c8941f6 --- /dev/null +++ b/internal/adapter/helpers_test.go @@ -0,0 +1,238 @@ +package adapter + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/cosmtrek/mindwalk/internal/model" +) + +func TestParseJSONInputEmpty(t *testing.T) { + for _, in := range []string{"", " ", "\n\t "} { + got := ParseJSONInput(in) + if got == nil { + t.Fatalf("ParseJSONInput(%q) returned nil", in) + } + + if len(got) != 0 { + t.Fatalf("ParseJSONInput(%q) = %v, want empty map", in, got) + } + } +} + +func TestParseJSONInputValidObject(t *testing.T) { + got := ParseJSONInput(`{"command":"ls","path":"foo"}`) + if got["command"] != "ls" || got["path"] != "foo" { + t.Fatalf("ParseJSONInput(object) = %v", got) + } +} + +func TestParseJSONInputNestedString(t *testing.T) { + got := ParseJSONInput(`"{\"command\":\"ls\"}"`) + if got["command"] != "ls" { + t.Fatalf("ParseJSONInput(nested string) = %v", got) + } +} + +func TestParseJSONInputMalformedFallsBackToRaw(t *testing.T) { + got := ParseJSONInput("not json at all") + if got["_raw"] != "not json at all" { + t.Fatalf("ParseJSONInput(malformed) = %v, want _raw fallback", got) + } +} + +func TestParseJSONInputNonObjectValueEncodedAsRaw(t *testing.T) { + got := ParseJSONInput("[1,2,3]") + if got["_raw"] != "[1,2,3]" { + t.Fatalf("ParseJSONInput(array) = %v, want _raw fallback to encoded value", got) + } +} + +func TestFallbackTitleSetsOnlyWhenEmpty(t *testing.T) { + t.Run("empty gets basename", func(t *testing.T) { + title := "" + FallbackTitle(&title, "/some/where/session.jsonl") + + if title != "session.jsonl" { + t.Fatalf("FallbackTitle empty = %q", title) + } + }) + t.Run("non-empty preserved", func(t *testing.T) { + title := "Already Set" + FallbackTitle(&title, "/some/where/session.jsonl") + + if title != "Already Set" { + t.Fatalf("FallbackTitle non-empty = %q", title) + } + }) +} + +func TestFallbackSessionTitleDelegates(t *testing.T) { + meta := model.SessionMeta{Title: ""} + FallbackSessionTitle(&meta, "/a/b/foo.jsonl") + + if meta.Title != "foo.jsonl" { + t.Fatalf("FallbackSessionTitle = %q", meta.Title) + } +} + +func TestFallbackTraceSessionTitleDelegates(t *testing.T) { + trace := &model.Trace{Session: model.TraceSession{}} + FallbackTraceSessionTitle(trace, "/a/b/bar.jsonl") + + if trace.Session.Title != "bar.jsonl" { + t.Fatalf("FallbackTraceSessionTitle = %q", trace.Session.Title) + } +} + +func TestApplySubagentLabelOnlyWhenEmpty(t *testing.T) { + t.Run("empty gets Subagent", func(t *testing.T) { + node := &model.AgentNode{} + ApplySubagentLabel(node) + + if node.Label != SubagentLabel { + t.Fatalf("label = %q", node.Label) + } + }) + t.Run("non-empty preserved", func(t *testing.T) { + node := &model.AgentNode{Label: "preset"} + ApplySubagentLabel(node) + + if node.Label != "preset" { + t.Fatalf("label = %q", node.Label) + } + }) +} + +func TestApplyLaunchNicknamePreferenceOrder(t *testing.T) { + t.Run("nickname wins when label empty", func(t *testing.T) { + node := &model.AgentNode{} + ApplyLaunchNickname(node, AgentLaunchOutput{Nickname: "runner"}) + + if node.Label != "runner" { + t.Fatalf("label = %q", node.Label) + } + }) + t.Run("existing label beats nickname", func(t *testing.T) { + node := &model.AgentNode{Label: "child"} + ApplyLaunchNickname(node, AgentLaunchOutput{Nickname: "runner"}) + + if node.Label != "child" { + t.Fatalf("label = %q", node.Label) + } + }) + t.Run("falls back to Subagent when both empty", func(t *testing.T) { + node := &model.AgentNode{} + ApplyLaunchNickname(node, AgentLaunchOutput{}) + + if node.Label != SubagentLabel { + t.Fatalf("label = %q, want Subagent fallback", node.Label) + } + }) +} + +func TestUnlinkedLaunchStatus(t *testing.T) { + cases := []struct { + name string + launch AgentLaunch + want string + }{ + {"empty output, observed", AgentLaunch{OutputObserved: true}, model.AgentStatusUnknown}, + {"empty output, not observed", AgentLaunch{}, model.AgentStatusUnknown}, + { + "valid JSON output, observed", + AgentLaunch{OutputObserved: true, Output: `{"id":1}`}, + model.AgentStatusUnknown, + }, + {"garbage output, observed", AgentLaunch{OutputObserved: true, Output: "not json"}, model.AgentStatusFailed}, + {"garbage output, not observed", AgentLaunch{Output: "not json"}, model.AgentStatusUnknown}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := UnlinkedLaunchStatus(c.launch); got != c.want { + t.Fatalf("got %q want %q", got, c.want) + } + }) + } +} + +func TestNormalizePathCollapsesEquivalents(t *testing.T) { + cwd, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + + abs := filepath.Join(cwd, "session.jsonl") + + cases := []struct { + in, want string + }{ + {abs, abs}, + {"./session.jsonl", abs}, + {"session.jsonl", abs}, + } + for _, c := range cases { + got := NormalizePath(c.in) + if got != c.want { + t.Fatalf("NormalizePath(%q) = %q, want %q", c.in, got, c.want) + } + } +} + +func TestSessionKeyUsesNormalizedPath(t *testing.T) { + base, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + + a := SessionKey("test", filepath.Join(base, "session.jsonl")) + + b := SessionKey("test", "./session.jsonl") + if a != b { + t.Fatalf("SessionKey should normalise path: %q != %q", a, b) + } +} + +func TestMindwalkHomeHonoursOverride(t *testing.T) { + t.Setenv("MINDWALK_HOME", "/tmp/custom-mindwalk") + + if got := MindwalkHome(); got != "/tmp/custom-mindwalk" { + t.Fatalf("MindwalkHome = %q, want override", got) + } +} + +func TestMindwalkHomeFallsBackToHomePath(t *testing.T) { + t.Setenv("MINDWALK_HOME", "") + t.Setenv("HOME", "/tmp/fake-home") + + if got := MindwalkHome(); got != filepath.Join("/tmp/fake-home", ".mindwalk") { + t.Fatalf("MindwalkHome = %q, want fallback under $HOME", got) + } +} + +func TestMindwalkHomeEmptyWhenHomeDirUnset(t *testing.T) { + t.Setenv("MINDWALK_HOME", "") + t.Setenv("HOME", "") + // UserHomeDir returns "" on platforms without HOME; the function + // should propagate that to its caller. + home := MindwalkHome() + if home != "" { + t.Fatalf("MindwalkHome = %q, want empty when no home resolvable", home) + } +} + +func TestAgentLaunchOutputJSONRoundTrip(t *testing.T) { + in := AgentLaunchOutput{AgentID: "a1", Nickname: "n1", TaskName: "t1"} + + data, err := json.Marshal(in) + if err != nil { + t.Fatal(err) + } + + want := `{"agent_id":"a1","nickname":"n1","task_name":"t1"}` + if string(data) != want { + t.Fatalf("marshal = %s, want %s", data, want) + } +} diff --git a/internal/adapter/home.go b/internal/adapter/home.go new file mode 100644 index 0000000..5560b09 --- /dev/null +++ b/internal/adapter/home.go @@ -0,0 +1,25 @@ +package adapter + +import "os" + +// mindwalkHomeEnv is the environment variable that overrides the +// default ~/.mindwalk base directory. Tests set it to a tempdir for +// isolation; CI overrides it for hermetic builds. +const mindwalkHomeEnv = "MINDWALK_HOME" + +// MindwalkHome resolves the mindwalk base directory used by every +// disk-backed cache (agent-graph cache, judge report cache). It honours +// the MINDWALK_HOME override and falls back to ~/.mindwalk when no +// override is set. Returns "" when no home directory can be resolved +// so callers can treat the empty result as "do not use the cache". +// +// Both the server (agent graph cache) and the CLI (cache subcommand) +// share this resolution so the MINDWALK_HOME contract — and any future +// extension like XDG path support — is enforced in one place. +func MindwalkHome() string { + if override := os.Getenv(mindwalkHomeEnv); override != "" { + return override + } + + return HomePath(".mindwalk") +} diff --git a/internal/adapter/input.go b/internal/adapter/input.go new file mode 100644 index 0000000..206e560 --- /dev/null +++ b/internal/adapter/input.go @@ -0,0 +1,50 @@ +package adapter + +import ( + "encoding/json" + "strings" +) + +// ParseJSONInput normalizes a tool-call input payload (which every +// adapter stores as a raw JSON string in its native log format) into +// the map[string]any shape the actionFor/targetsFor helpers expect. +// +// The function is forgiving by design: tool inputs are user-facing +// strings that the harness never validates before persistence, so +// malformed payloads must not block trace rendering. The fallback +// strategy is: +// +// - empty / whitespace-only → empty map (caller treats as no input) +// - JSON object → returned as the decoded map +// - JSON string → re-parsed recursively (Crush's view tool nests a +// one-key object as a JSON literal, codex does the same for some +// apply_patch payloads) +// - other JSON value → re-encoded as a "_raw" entry so downstream +// heuristics can still mine the bytes +// - malformed → original text preserved as a "_raw" entry +// +// The same rule is reused by every adapter; the per-adapter helpers +// in codex and crush used to re-implement this loop with slight +// variations, all of which converged on the same behaviour. +func ParseJSONInput(raw string) map[string]any { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return map[string]any{} + } + + var value any + if err := json.Unmarshal([]byte(trimmed), &value); err != nil { + return map[string]any{"_raw": raw} + } + + switch v := value.(type) { + case map[string]any: + return v + case string: + return ParseJSONInput(v) + default: + encoded, _ := json.Marshal(value) + + return map[string]any{"_raw": string(encoded)} + } +} diff --git a/internal/adapter/pi/adapter.go b/internal/adapter/pi/adapter.go index 1772771..b178eaf 100644 --- a/internal/adapter/pi/adapter.go +++ b/internal/adapter/pi/adapter.go @@ -2,9 +2,9 @@ package pi import ( "encoding/json" - "fmt" "os" "path/filepath" + "slices" "sort" "strings" @@ -17,11 +17,7 @@ type Adapter struct { } func DefaultDir() string { - home, err := os.UserHomeDir() - if err != nil { - return "" - } - return filepath.Join(home, ".pi", "agent", "sessions") + return adapter.HomePath(".pi", "agent", "sessions") } func (a Adapter) Harness() string { @@ -32,34 +28,56 @@ func (a Adapter) SessionDir() string { if a.Dir != "" { return a.Dir } + return DefaultDir() } +// sessionIDFromPath derives a stable id for a pi session file. The +// filename without extension is the conventional id; a session header +// that records its own id wins (pi uses a generated id that survives +// resume and rename). Both `Summarize` and `Parse` need the same +// derivation, so the rule lives here. +func sessionIDFromPath(path, headerID string) string { + id := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)) + if headerID != "" { + id = headerID + } + + return id +} + func (a Adapter) ListSessions() ([]model.SessionMeta, error) { dir := a.SessionDir() - if info, err := os.Stat(dir); err != nil || !info.IsDir() { + if !adapter.ReadableDir(dir) { return nil, nil } + var metas []model.SessionMeta + err := filepath.WalkDir(dir, func(path string, entry os.DirEntry, walkErr error) error { if walkErr != nil { return nil } + if entry.IsDir() || filepath.Ext(path) != ".jsonl" { return nil } + meta, err := a.Summarize(path) if err == nil { metas = append(metas, meta) } + return nil }) if err != nil { return nil, err } + sort.Slice(metas, func(i, j int) bool { return metas[i].EndedAt > metas[j].EndedAt }) + return metas, nil } @@ -68,14 +86,12 @@ func (a Adapter) Summarize(path string) (model.SessionMeta, error) { if err != nil && !recognized { return model.SessionMeta{}, err } + if !recognized { - return model.SessionMeta{}, fmt.Errorf("not a pi session: %s", path) + return model.SessionMeta{}, adapter.NotRecognizedErr(a.Harness(), path) } - id := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)) - if header.ID != "" { - id = header.ID - } + id := sessionIDFromPath(path, header.ID) meta := model.SessionMeta{ Key: adapter.SessionKey(a.Harness(), path), ID: id, @@ -90,13 +106,16 @@ func (a Adapter) Summarize(path string) (model.SessionMeta, error) { // compares the two, and a whole-file count would mark every branched // session's fresh report stale. firstUserText := "" + for _, entry := range linearize(entries) { if entry.Timestamp != "" { if meta.StartedAt == "" { meta.StartedAt = entry.Timestamp } + meta.EndedAt = entry.Timestamp } + switch entry.Type { case "model_change": if entry.ModelID != "" { @@ -107,11 +126,13 @@ func (a Adapter) Summarize(path string) (model.SessionMeta, error) { if err != nil { continue } + switch msg.Role { case "assistant": if msg.Model != "" && meta.Model == "" { meta.Model = msg.Model } + meta.EventCount += countToolCalls(msg.Content) case "user": // mirrors Parse's user-message mark filter so the badge's @@ -119,6 +140,7 @@ func (a Adapter) Summarize(path string) (model.SessionMeta, error) { text := contentText(msg.Content) if !adapter.InjectedUserMessage(text) { meta.UserTurns++ + if firstUserText == "" { firstUserText = text } @@ -128,7 +150,9 @@ func (a Adapter) Summarize(path string) (model.SessionMeta, error) { } } } + meta.Title = sessionTitle(entries, firstUserText, path) + return meta, err } @@ -137,14 +161,12 @@ func (a Adapter) Parse(path string) (*model.Trace, error) { if err != nil && !recognized { return nil, err } + if !recognized { - return nil, fmt.Errorf("not a pi session: %s", path) + return nil, adapter.NotRecognizedErr(a.Harness(), path) } - id := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)) - if header.ID != "" { - id = header.ID - } + id := sessionIDFromPath(path, header.ID) trace := &model.Trace{ Version: 1, Session: model.TraceSession{ @@ -162,13 +184,16 @@ func (a Adapter) Parse(path string) (*model.Trace, error) { pending := map[string]adapter.ToolCall{} pendingOrder := []string{} firstUserText := "" + for _, entry := range linearize(entries) { if entry.Timestamp != "" { if trace.Session.StartedAt == "" { trace.Session.StartedAt = entry.Timestamp } + trace.Session.EndedAt = entry.Timestamp } + switch entry.Type { case "model_change": // A model_change records a deliberate user switch, so it wins @@ -195,6 +220,7 @@ func (a Adapter) Parse(path string) (*model.Trace, error) { if err != nil { continue } + switch msg.Role { case "user": text := contentText(msg.Content) @@ -212,10 +238,12 @@ func (a Adapter) Parse(path string) (*model.Trace, error) { if msg.Model != "" && trace.Session.Model == "" { trace.Session.Model = msg.Model } + for _, block := range contentBlocks(msg.Content) { if block.Type != "toolCall" || block.ID == "" { continue } + call := adapter.ToolCall{ ID: block.ID, Name: block.Name, @@ -225,6 +253,7 @@ func (a Adapter) Parse(path string) (*model.Trace, error) { if _, exists := pending[call.ID]; !exists { pendingOrder = append(pendingOrder, call.ID) } + pending[call.ID] = call } case "toolResult": @@ -232,6 +261,7 @@ func (a Adapter) Parse(path string) (*model.Trace, error) { if !ok { continue } + delete(pending, msg.ToolCallID) isError := msg.IsError != nil && *msg.IsError trace.Events = append(trace.Events, adapter.BuildEvent(trace, call, adapter.ToolResult{ @@ -255,15 +285,18 @@ func (a Adapter) Parse(path string) (*model.Trace, error) { } } } + for _, id := range pendingOrder { if call, ok := pending[id]; ok { trace.Events = append(trace.Events, adapter.BuildEvent(trace, call, adapter.ToolResult{})) } } + trace.Session.Title = sessionTitle(entries, firstUserText, path) trace.Session.EventCount = len(trace.Events) // pi tool results carry an isError flag set by the harness. trace.Stats = model.ComputeStats(trace, 0, model.ObservabilitySignals{Errors: model.ObservabilityExact}) + return trace, err } @@ -274,38 +307,45 @@ func (a Adapter) Parse(path string) (*model.Trace, error) { func linearize(entries []rawEntry) []rawEntry { leaf := -1 index := map[string]int{} + for i, entry := range entries { if entry.ID != "" { leaf = i index[entry.ID] = i } } + if leaf < 0 { return entries } + var path []int + visited := map[int]bool{} + cur := leaf - for { - if visited[cur] { - break - } + for !visited[cur] { visited[cur] = true path = append(path, cur) + parent := entries[cur].ParentID if parent == "" { break } + next, ok := index[parent] if !ok { break } + cur = next } + ordered := make([]rawEntry, 0, len(path)) - for i := len(path) - 1; i >= 0; i-- { - ordered = append(ordered, entries[path[i]]) + for _, v := range slices.Backward(path) { + ordered = append(ordered, entries[v]) } + return ordered } @@ -337,26 +377,34 @@ func decodePiMessage(data json.RawMessage) (piMessage, error) { if err := json.Unmarshal(data, &msg); err != nil { return piMessage{}, err } + var fields map[string]json.RawMessage if err := json.Unmarshal(data, &fields); err != nil { return piMessage{}, err } + msg.IsError = nil + if raw, ok := fields["isError"]; ok && strings.TrimSpace(string(raw)) != "null" { var value bool if err := json.Unmarshal(raw, &value); err != nil { return piMessage{}, err } + msg.IsError = &value } + msg.ExitCode = nil + if raw, ok := fields["exitCode"]; ok && strings.TrimSpace(string(raw)) != "null" { var value int if err := json.Unmarshal(raw, &value); err != nil { return piMessage{}, err } + msg.ExitCode = &value } + return msg, nil } @@ -383,6 +431,7 @@ func isPiHeader(data []byte) bool { if json.Unmarshal(data, &probe) != nil { return false } + return probe.Type == "session" && len(probe.ID) > 0 && probe.ID[0] == '"' } @@ -392,11 +441,11 @@ func isPiHeader(data []byte) bool { // the file is not a pi session. Entries are only collected once the header // is accepted. func readSession(path string) (header rawEntry, entries []rawEntry, recognized bool, err error) { - f, err := os.Open(path) + f, closeFile, err := adapter.OpenFile(path) if err != nil { return rawEntry{}, nil, false, err } - defer f.Close() + defer closeFile() sawEntry := false err = adapter.ReadJSONLines(f, func(data []byte) { @@ -408,20 +457,26 @@ func readSession(path string) (header rawEntry, entries []rawEntry, recognized b // Valid JSON in a shape rawEntry cannot hold still counts as // the first parsed entry for pi — and it is not a header. sawEntry = true + return } + if !sawEntry { sawEntry = true + if isPiHeader(data) { header = entry recognized = true } + return } + if recognized && entry.Type != "" { entries = append(entries, entry) } }) + return header, entries, recognized, err } @@ -430,18 +485,22 @@ func readSession(path string) (header rawEntry, entries []rawEntry, recognized b // Without one, the trunk's first user message names the session, then the // file name. func sessionTitle(entries []rawEntry, firstUserText, path string) string { - for i := len(entries) - 1; i >= 0; i-- { - if entries[i].Type != "session_info" { + for _, v := range slices.Backward(entries) { + if v.Type != "session_info" { continue } - if entries[i].Name != "" { - return entries[i].Name + + if v.Name != "" { + return v.Name } + break } + if firstUserText != "" { return adapter.AgentInstructionPreview(firstUserText) } + return filepath.Base(path) } @@ -449,24 +508,29 @@ func contentBlocks(raw json.RawMessage) []contentBlock { if len(raw) == 0 { return nil } + var blocks []contentBlock if json.Unmarshal(raw, &blocks) == nil { return blocks } + var s string if json.Unmarshal(raw, &s) == nil && s != "" { return []contentBlock{{Type: "text", Text: s}} } + return nil } func contentText(raw json.RawMessage) string { var parts []string + for _, block := range contentBlocks(raw) { if block.Type == "text" && strings.TrimSpace(block.Text) != "" { parts = append(parts, strings.TrimSpace(block.Text)) } } + return strings.Join(parts, "\n") } @@ -474,10 +538,12 @@ func contentText(raw json.RawMessage) string { // blocks without an id are skipped there too. func countToolCalls(raw json.RawMessage) int { count := 0 + for _, block := range contentBlocks(raw) { if block.Type == "toolCall" && block.ID != "" { count++ } } + return count } diff --git a/internal/adapter/pi/adapter_test.go b/internal/adapter/pi/adapter_test.go index 65949a4..a503461 100644 --- a/internal/adapter/pi/adapter_test.go +++ b/internal/adapter/pi/adapter_test.go @@ -22,19 +22,29 @@ func writeSession(t *testing.T, lines ...string) string { } func header(cwd string) string { - return fmt.Sprintf(`{"type":"session","version":3,"id":"sess-1","timestamp":"2026-07-21T14:34:07.238Z","cwd":%q}`, cwd) + return fmt.Sprintf( + `{"type":"session","version":3,"id":"sess-1","timestamp":"2026-07-21T14:34:07.238Z","cwd":%q}`, + cwd, + ) } func TestParseLinearSession(t *testing.T) { root := t.TempDir() mainGo := filepath.Join(root, "src", "main.go") - path := writeSession(t, + path := writeSession( + t, header(root), `{"type":"model_change","id":"e1","parentId":null,"timestamp":"2026-07-21T14:34:07.287Z","provider":"moonshotai","modelId":"kimi-k3"}`, `{"type":"message","id":"e2","parentId":"e1","timestamp":"2026-07-21T14:34:22.963Z","message":{"role":"user","content":[{"type":"text","text":"fix the bug"}],"timestamp":1784644462960}}`, - fmt.Sprintf(`{"type":"message","id":"e3","parentId":"e2","timestamp":"2026-07-21T14:34:25.000Z","message":{"role":"assistant","content":[{"type":"toolCall","id":"t1","name":"read","arguments":{"path":%q}}],"model":"kimi-k3","stopReason":"toolUse"}}`, mainGo), + fmt.Sprintf( + `{"type":"message","id":"e3","parentId":"e2","timestamp":"2026-07-21T14:34:25.000Z","message":{"role":"assistant","content":[{"type":"toolCall","id":"t1","name":"read","arguments":{"path":%q}}],"model":"kimi-k3","stopReason":"toolUse"}}`, + mainGo, + ), `{"type":"message","id":"e4","parentId":"e3","timestamp":"2026-07-21T14:34:26.000Z","message":{"role":"toolResult","toolCallId":"t1","toolName":"read","content":[{"type":"text","text":"package main"}],"isError":false}}`, - fmt.Sprintf(`{"type":"message","id":"e5","parentId":"e4","timestamp":"2026-07-21T14:34:27.000Z","message":{"role":"assistant","content":[{"type":"toolCall","id":"t2","name":"edit","arguments":{"path":%q,"edits":[{"oldText":"a","newText":"b"}]}}],"model":"kimi-k3","stopReason":"toolUse"}}`, mainGo), + fmt.Sprintf( + `{"type":"message","id":"e5","parentId":"e4","timestamp":"2026-07-21T14:34:27.000Z","message":{"role":"assistant","content":[{"type":"toolCall","id":"t2","name":"edit","arguments":{"path":%q,"edits":[{"oldText":"a","newText":"b"}]}}],"model":"kimi-k3","stopReason":"toolUse"}}`, + mainGo, + ), `{"type":"message","id":"e6","parentId":"e5","timestamp":"2026-07-21T14:34:28.000Z","message":{"role":"toolResult","toolCallId":"t2","toolName":"edit","content":[{"type":"text","text":"oldText not found"}],"isError":true}}`, ) @@ -81,7 +91,8 @@ func TestParseLinearSession(t *testing.T) { func TestParsePreservesToolOutcomeCertainty(t *testing.T) { root := t.TempDir() - path := writeSession(t, + path := writeSession( + t, header(root), `{"type":"message","id":"a1","parentId":null,"timestamp":"2026-07-21T14:35:01.000Z","message":{"role":"assistant","content":[{"type":"toolCall","id":"success","name":"read","arguments":{"path":"ok.go"}},{"type":"toolCall","id":"failure","name":"edit","arguments":{"path":"bad.go"}},{"type":"toolCall","id":"unknown","name":"bash","arguments":{"command":"sleep 100"}},{"type":"toolCall","id":"wrong-case","name":"edit","arguments":{"path":"case.go"}}],"stopReason":"toolUse"}}`, `{"type":"message","id":"r1","parentId":"a1","timestamp":"2026-07-21T14:35:02.000Z","message":{"role":"toolResult","toolCallId":"success","toolName":"read","content":[{"type":"text","text":"ok"}],"isError":false}}`, @@ -94,21 +105,27 @@ func TestParsePreservesToolOutcomeCertainty(t *testing.T) { if err != nil { t.Fatal(err) } + if len(trace.Events) != 4 { t.Fatalf("events = %#v", trace.Events) } + if got := trace.Events[0]; !got.OutcomeKnown || got.IsError { t.Fatalf("success event = %#v", got) } + if got := trace.Events[1]; !got.OutcomeKnown || !got.IsError { t.Fatalf("failure event = %#v", got) } + if got := trace.Events[2]; got.OutcomeKnown || got.IsError { t.Fatalf("unknown event = %#v", got) } + if got := trace.Events[3]; got.OutcomeKnown || got.IsError { t.Fatalf("wrong-case error key event = %#v", got) } + if trace.Stats.Observability.Errors != "estimated" { t.Fatalf("observability = %#v", trace.Stats.Observability) } @@ -118,13 +135,20 @@ func TestParseFollowsTrunkAcrossBranches(t *testing.T) { root := t.TempDir() fileA := filepath.Join(root, "a.go") fileB := filepath.Join(root, "b.go") - path := writeSession(t, + path := writeSession( + t, header(root), `{"type":"message","id":"u1","parentId":null,"timestamp":"2026-07-21T14:35:00.000Z","message":{"role":"user","content":[{"type":"text","text":"try something"}],"timestamp":1}}`, - fmt.Sprintf(`{"type":"message","id":"a1","parentId":"u1","timestamp":"2026-07-21T14:35:01.000Z","message":{"role":"assistant","content":[{"type":"toolCall","id":"t1","name":"write","arguments":{"path":%q,"content":"x"}}],"stopReason":"toolUse"}}`, fileA), + fmt.Sprintf( + `{"type":"message","id":"a1","parentId":"u1","timestamp":"2026-07-21T14:35:01.000Z","message":{"role":"assistant","content":[{"type":"toolCall","id":"t1","name":"write","arguments":{"path":%q,"content":"x"}}],"stopReason":"toolUse"}}`, + fileA, + ), `{"type":"message","id":"r1","parentId":"a1","timestamp":"2026-07-21T14:35:02.000Z","message":{"role":"toolResult","toolCallId":"t1","toolName":"write","content":[{"type":"text","text":"ok"}],"isError":false}}`, `{"type":"branch_summary","id":"b1","parentId":"u1","timestamp":"2026-07-21T14:36:00.000Z","fromId":"r1","summary":"abandoned the first approach"}`, - fmt.Sprintf(`{"type":"message","id":"a2","parentId":"b1","timestamp":"2026-07-21T14:36:01.000Z","message":{"role":"assistant","content":[{"type":"toolCall","id":"t2","name":"write","arguments":{"path":%q,"content":"y"}}],"stopReason":"toolUse"}}`, fileB), + fmt.Sprintf( + `{"type":"message","id":"a2","parentId":"b1","timestamp":"2026-07-21T14:36:01.000Z","message":{"role":"assistant","content":[{"type":"toolCall","id":"t2","name":"write","arguments":{"path":%q,"content":"y"}}],"stopReason":"toolUse"}}`, + fileB, + ), `{"type":"message","id":"r2","parentId":"a2","timestamp":"2026-07-21T14:36:02.000Z","message":{"role":"toolResult","toolCallId":"t2","toolName":"write","content":[{"type":"text","text":"ok"}],"isError":false}}`, ) @@ -152,10 +176,17 @@ func TestParseFollowsTrunkAcrossBranches(t *testing.T) { func TestParseV1LinearWithoutIDs(t *testing.T) { root := t.TempDir() - path := writeSession(t, - fmt.Sprintf(`{"type":"session","version":1,"id":"sess-v1","timestamp":"2026-07-21T14:34:07.238Z","cwd":%q}`, root), + path := writeSession( + t, + fmt.Sprintf( + `{"type":"session","version":1,"id":"sess-v1","timestamp":"2026-07-21T14:34:07.238Z","cwd":%q}`, + root, + ), `{"type":"message","timestamp":"2026-07-21T14:34:22.000Z","message":{"role":"user","content":"hello","timestamp":1}}`, - fmt.Sprintf(`{"type":"message","timestamp":"2026-07-21T14:34:23.000Z","message":{"role":"assistant","content":[{"type":"toolCall","id":"t1","name":"read","arguments":{"path":%q}}],"stopReason":"toolUse"}}`, filepath.Join(root, "x.go")), + fmt.Sprintf( + `{"type":"message","timestamp":"2026-07-21T14:34:23.000Z","message":{"role":"assistant","content":[{"type":"toolCall","id":"t1","name":"read","arguments":{"path":%q}}],"stopReason":"toolUse"}}`, + filepath.Join(root, "x.go"), + ), `{"type":"message","timestamp":"2026-07-21T14:34:24.000Z","message":{"role":"toolResult","toolCallId":"t1","toolName":"read","content":[{"type":"text","text":"data"}],"isError":false}}`, ) @@ -172,7 +203,8 @@ func TestParseV1LinearWithoutIDs(t *testing.T) { } func TestParseRejectsOtherSources(t *testing.T) { - claudeFile := writeSession(t, + claudeFile := writeSession( + t, `{"type":"user","sessionId":"c1","timestamp":"2026-07-21T14:34:07.238Z","cwd":"/tmp","message":{"role":"user","content":"hi"}}`, ) codexFile := writeSession(t, @@ -194,7 +226,8 @@ func TestParseRejectsOtherSources(t *testing.T) { // The reverse direction: the other adapters must not claim a pi file, // or trial-parse dispatch would route pi sessions to the wrong source. - piFile := writeSession(t, + piFile := writeSession( + t, header(t.TempDir()), `{"type":"message","id":"e1","parentId":null,"timestamp":"2026-07-21T14:34:22.963Z","message":{"role":"user","content":[{"type":"text","text":"hello"}],"timestamp":1}}`, ) @@ -208,7 +241,8 @@ func TestParseRejectsOtherSources(t *testing.T) { func TestParseBashExecution(t *testing.T) { root := t.TempDir() - path := writeSession(t, + path := writeSession( + t, header(root), `{"type":"message","id":"e1","parentId":null,"timestamp":"2026-07-21T14:35:00.000Z","message":{"role":"bashExecution","command":"npm run build","output":"boom","exitCode":1,"cancelled":false,"truncated":false,"timestamp":1}}`, `{"type":"message","id":"e2","parentId":"e1","timestamp":"2026-07-21T14:35:10.000Z","message":{"role":"bashExecution","command":"go test ./...","output":"ok","exitCode":0,"cancelled":false,"truncated":false,"timestamp":2}}`, @@ -220,18 +254,23 @@ func TestParseBashExecution(t *testing.T) { if err != nil { t.Fatal(err) } + if len(trace.Events) != 4 { t.Fatalf("events = %#v", trace.Events) } + if trace.Events[0].Tool != "bash" || !trace.Events[0].OutcomeKnown || !trace.Events[0].IsError { t.Fatalf("failed command event = %#v", trace.Events[0]) } + if trace.Events[1].Action != "verify" || !trace.Events[1].OutcomeKnown || trace.Events[1].IsError { t.Fatalf("verify event = %#v", trace.Events[1]) } + if trace.Events[2].OutcomeKnown || trace.Events[2].IsError { t.Fatalf("cancelled command without exit code has unknown outcome: %#v", trace.Events[2]) } + if trace.Events[3].OutcomeKnown || trace.Events[3].IsError { t.Fatalf("wrong-case exit code has unknown outcome: %#v", trace.Events[3]) } @@ -239,7 +278,8 @@ func TestParseBashExecution(t *testing.T) { func TestParseExtensionChannels(t *testing.T) { root := t.TempDir() - path := writeSession(t, + path := writeSession( + t, header(root), `{"type":"custom","id":"e1","parentId":null,"timestamp":"2026-07-21T14:35:00.000Z","customType":"my-state","data":{"count":42}}`, `{"type":"custom_message","id":"e2","parentId":"e1","timestamp":"2026-07-21T14:35:01.000Z","customType":"handoff","content":"resume from the previous session","display":true}`, @@ -269,9 +309,13 @@ func TestParseExtensionChannels(t *testing.T) { func TestParseFlushesUnpairedToolCalls(t *testing.T) { root := t.TempDir() - path := writeSession(t, + path := writeSession( + t, header(root), - fmt.Sprintf(`{"type":"message","id":"e1","parentId":null,"timestamp":"2026-07-21T14:35:00.000Z","message":{"role":"assistant","content":[{"type":"toolCall","id":"t1","name":"read","arguments":{"path":%q}}],"stopReason":"aborted"}}`, filepath.Join(root, "x.go")), + fmt.Sprintf( + `{"type":"message","id":"e1","parentId":null,"timestamp":"2026-07-21T14:35:00.000Z","message":{"role":"assistant","content":[{"type":"toolCall","id":"t1","name":"read","arguments":{"path":%q}}],"stopReason":"aborted"}}`, + filepath.Join(root, "x.go"), + ), ) trace, err := Adapter{}.Parse(path) @@ -285,7 +329,8 @@ func TestParseFlushesUnpairedToolCalls(t *testing.T) { func TestParseCompactionMark(t *testing.T) { root := t.TempDir() - path := writeSession(t, + path := writeSession( + t, header(root), `{"type":"message","id":"e1","parentId":null,"timestamp":"2026-07-21T14:35:00.000Z","message":{"role":"user","content":"long task","timestamp":1}}`, `{"type":"compaction","id":"e2","parentId":"e1","timestamp":"2026-07-21T15:00:00.000Z","summary":"did things","firstKeptEntryId":"e1","tokensBefore":50000}`, @@ -305,7 +350,8 @@ func TestParseCompactionMark(t *testing.T) { func TestSummarize(t *testing.T) { root := t.TempDir() - path := writeSession(t, + path := writeSession( + t, header(root), `{"type":"model_change","id":"e1","parentId":null,"timestamp":"2026-07-21T14:34:07.287Z","provider":"moonshotai","modelId":"kimi-k3"}`, `{"type":"message","id":"e2","parentId":"e1","timestamp":"2026-07-21T14:34:22.963Z","message":{"role":"user","content":[{"type":"text","text":"first task"}],"timestamp":1}}`, @@ -341,7 +387,8 @@ func TestSummarize(t *testing.T) { func TestSummarizeTitleFallsBackToFirstUserMessage(t *testing.T) { root := t.TempDir() - path := writeSession(t, + path := writeSession( + t, header(root), `{"type":"message","id":"e1","parentId":null,"timestamp":"2026-07-21T14:34:22.963Z","message":{"role":"user","content":[{"type":"text","text":"rename the config loader"}],"timestamp":1}}`, ) @@ -382,11 +429,13 @@ func TestListSessions(t *testing.T) { func TestParseAcceptsCwdlessLegacySession(t *testing.T) { // pi's own readSessionHeader only requires type "session" plus a string // id; legacy sessions can lack a cwd entirely and must still open. - noCwdField := writeSession(t, + noCwdField := writeSession( + t, `{"type":"session","version":1,"id":"legacy-1","timestamp":"2026-07-21T14:34:07.238Z"}`, `{"type":"message","timestamp":"2026-07-21T14:34:22.000Z","message":{"role":"user","content":"hello","timestamp":1}}`, ) - emptyCwd := writeSession(t, + emptyCwd := writeSession( + t, `{"type":"session","version":3,"id":"legacy-2","timestamp":"2026-07-21T14:34:07.238Z","cwd":""}`, `{"type":"message","id":"e1","parentId":null,"timestamp":"2026-07-21T14:34:22.000Z","message":{"role":"user","content":"hello","timestamp":1}}`, ) @@ -419,14 +468,21 @@ func TestSummarizeMatchesParseOnBranchedSession(t *testing.T) { root := t.TempDir() fileA := filepath.Join(root, "a.go") fileB := filepath.Join(root, "b.go") - path := writeSession(t, + path := writeSession( + t, header(root), `{"type":"message","id":"u1","parentId":null,"timestamp":"2026-07-21T14:35:00.000Z","message":{"role":"user","content":[{"type":"text","text":"try something"}],"timestamp":1}}`, - fmt.Sprintf(`{"type":"message","id":"a1","parentId":"u1","timestamp":"2026-07-21T14:35:01.000Z","message":{"role":"assistant","content":[{"type":"toolCall","id":"t1","name":"write","arguments":{"path":%q,"content":"x"}}],"stopReason":"toolUse"}}`, fileA), + fmt.Sprintf( + `{"type":"message","id":"a1","parentId":"u1","timestamp":"2026-07-21T14:35:01.000Z","message":{"role":"assistant","content":[{"type":"toolCall","id":"t1","name":"write","arguments":{"path":%q,"content":"x"}}],"stopReason":"toolUse"}}`, + fileA, + ), `{"type":"message","id":"r1","parentId":"a1","timestamp":"2026-07-21T14:35:02.000Z","message":{"role":"toolResult","toolCallId":"t1","toolName":"write","content":[{"type":"text","text":"ok"}],"isError":false}}`, `{"type":"message","id":"u2","parentId":"r1","timestamp":"2026-07-21T14:35:03.000Z","message":{"role":"user","content":[{"type":"text","text":"abandoned follow-up"}],"timestamp":2}}`, `{"type":"branch_summary","id":"b1","parentId":"u1","timestamp":"2026-07-21T14:36:00.000Z","fromId":"u2","summary":"left the first attempt"}`, - fmt.Sprintf(`{"type":"message","id":"a2","parentId":"b1","timestamp":"2026-07-21T14:36:01.000Z","message":{"role":"assistant","content":[{"type":"toolCall","id":"t2","name":"write","arguments":{"path":%q,"content":"y"}}],"stopReason":"toolUse"}}`, fileB), + fmt.Sprintf( + `{"type":"message","id":"a2","parentId":"b1","timestamp":"2026-07-21T14:36:01.000Z","message":{"role":"assistant","content":[{"type":"toolCall","id":"t2","name":"write","arguments":{"path":%q,"content":"y"}}],"stopReason":"toolUse"}}`, + fileB, + ), `{"type":"message","id":"r2","parentId":"a2","timestamp":"2026-07-21T14:36:02.000Z","message":{"role":"toolResult","toolCallId":"t2","toolName":"write","content":[{"type":"text","text":"ok"}],"isError":false}}`, ) @@ -456,7 +512,8 @@ func TestSummarizeMatchesParseOnBranchedSession(t *testing.T) { func TestSessionTitleClearedByEmptySessionInfo(t *testing.T) { root := t.TempDir() - path := writeSession(t, + path := writeSession( + t, header(root), `{"type":"message","id":"e1","parentId":null,"timestamp":"2026-07-21T14:34:22.963Z","message":{"role":"user","content":[{"type":"text","text":"rename the loader"}],"timestamp":1}}`, `{"type":"session_info","id":"e2","parentId":"e1","timestamp":"2026-07-21T14:34:23.000Z","name":"Named"}`, diff --git a/internal/adapter/pi/diagnostics.go b/internal/adapter/pi/diagnostics.go new file mode 100644 index 0000000..2593609 --- /dev/null +++ b/internal/adapter/pi/diagnostics.go @@ -0,0 +1,7 @@ +package pi + +import "github.com/cosmtrek/mindwalk/internal/adapter" + +func (a Adapter) Diagnostics() []adapter.DiagnosticCheck { + return adapter.FilesystemDiagnostics(a.SessionDir(), ".jsonl") +} diff --git a/internal/adapter/property_test.go b/internal/adapter/property_test.go new file mode 100644 index 0000000..b6a0345 --- /dev/null +++ b/internal/adapter/property_test.go @@ -0,0 +1,119 @@ +package adapter + +import ( + "strings" + "testing" + "testing/quick" + "unicode/utf8" +) + +// TestNormalizePathIdempotentRelative verifies that normalizing a relative +// path that resolves inside the repo produces a result that, when normalized +// again, yields the same path. This catches any path-mangling regression. +func TestNormalizePathIdempotentRelative(t *testing.T) { + cwd := "/home/user/repo" + base := "/home/user/repo" + + property := func(path string) bool { + if path == "" { + return true + } + + rel, outside, ok := normalizePath(cwd, base, path) + if !ok || outside != nil || rel == "" { + return true + } + + rel2, _, ok2 := normalizePath(cwd, base, rel) + + return ok2 && rel2 == rel + } + if err := quick.Check(property, &quick.Config{MaxCount: 500}); err != nil { + t.Fatal(err) + } +} + +// TestNormalizePathRejectsEmpty verifies that empty or whitespace-only paths +// are always rejected. +func TestNormalizePathRejectsEmpty(t *testing.T) { + property := func(s string) bool { + var trimmed strings.Builder + + for _, r := range s { + if r == ' ' || r == '\t' || r == '\n' || r == '\r' || r == '"' || r == '\'' { + trimmed.WriteString("") + } else { + break + } + } + + _, _, ok := normalizePath("/cwd", "/cwd", trimmed.String()) + + return !ok + } + if err := quick.Check(property, &quick.Config{MaxCount: 100}); err != nil { + t.Fatal(err) + } +} + +// TestNormalizePathRejectsHTTP verifies that URLs are never treated as paths. +func TestNormalizePathRejectsHTTP(t *testing.T) { + for _, prefix := range []string{"http://", "https://"} { + path := prefix + "example.com/file.go" + + _, _, ok := normalizePath("/cwd", "/cwd", path) + if ok { + t.Fatalf("normalizePath accepted %q", path) + } + } +} + +// TestTruncateNoteNeverExceedsMaxRunes verifies that the result of +// truncateNote is always <= maxRunes runes when maxRunes > 0. +func TestTruncateNoteNeverExceedsMaxRunes(t *testing.T) { + property := func(s string, maxRunes uint8) bool { + if maxRunes == 0 { + return true + } + + got := truncateNoteShim(s, int(maxRunes)) + if utf8.RuneCountInString(got) > int(maxRunes)+1 { // +1 for ellipsis + return false + } + + return true + } + if err := quick.Check(property, nil); err != nil { + t.Fatal(err) + } +} + +// TestTruncateNotePreservesShortStrings verifies that strings already within +// the limit are returned unchanged. +func TestTruncateNotePreservesShortStrings(t *testing.T) { + property := func(s string) bool { + runeCount := utf8.RuneCountInString(s) + if runeCount > 200 { + return true + } + + got := truncateNoteShim(s, 200) + + return got == s + } + if err := quick.Check(property, nil); err != nil { + t.Fatal(err) + } +} + +// truncateNoteShim calls the crush package's truncateNote via a shared test +// helper. Since truncateNote lives in crush, we replicate the logic here for +// property testing — any drift would be caught by crush's own unit tests. +func truncateNoteShim(s string, maxRunes int) string { + runes := []rune(s) + if len(runes) <= maxRunes { + return s + } + + return string(runes[:maxRunes]) + "\u2026" +} diff --git a/internal/adapter/title.go b/internal/adapter/title.go new file mode 100644 index 0000000..4a2d7fb --- /dev/null +++ b/internal/adapter/title.go @@ -0,0 +1,32 @@ +package adapter + +import ( + "path/filepath" + + "github.com/cosmtrek/mindwalk/internal/model" +) + +// FallbackTitle sets *target to filepath.Base(path) when the current +// value is empty. Every JSONL adapter records a session title in its +// native log (or computes one from a sidecar), but the fallback when +// nothing was recorded has to be the file path basename so the rail +// and trace views show something useful. Centralising the rule avoids +// adapters drifting on whether to use filepath.Base or just the +// filename stem. +func FallbackTitle(target *string, path string) { + if *target == "" { + *target = filepath.Base(path) + } +} + +// FallbackSessionTitle is the SessionMeta-shaped convenience wrapper +// around FallbackTitle. +func FallbackSessionTitle(meta *model.SessionMeta, path string) { + FallbackTitle(&meta.Title, path) +} + +// FallbackTraceSessionTitle is the model.TraceSession-shaped +// convenience wrapper around FallbackTitle. +func FallbackTraceSessionTitle(trace *model.Trace, path string) { + FallbackTitle(&trace.Session.Title, path) +} From 1cc58f0b1871f20e1abfa657e79f673188fae5e9 Mon Sep 17 00:00:00 2001 From: Lars Artmann Date: Wed, 9 Sep 2026 04:39:46 +0200 Subject: [PATCH 3/3] feat(crush): session adapter for Crush (charmbracelet/crush) --- .gitignore | 2 + go.mod | 18 +- go.sum | 58 +- internal/adapter/crush/adapter.go | 302 ++++++ internal/adapter/crush/adapter_test.go | 1039 ++++++++++++++++++++ internal/adapter/crush/agents.go | 464 +++++++++ internal/adapter/crush/agents_test.go | 374 +++++++ internal/adapter/crush/diagnostics.go | 133 +++ internal/adapter/crush/diagnostics_test.go | 151 +++ internal/adapter/crush/fixture_test.go | 376 +++++++ internal/adapter/crush/fuzz_test.go | 30 + internal/adapter/crush/handle.go | 78 ++ internal/adapter/crush/parts.go | 284 ++++++ internal/adapter/crush/parts_test.go | 419 ++++++++ internal/adapter/crush/projects.go | 125 +++ internal/adapter/crush/sessions.go | 923 +++++++++++++++++ internal/adapter/crush/sessions_test.go | 586 +++++++++++ internal/adapter/crush/stress_test.go | 168 ++++ internal/adapter/crush/todo_spike_test.go | 84 ++ schema/agent-graph.schema.json | 1 + testdata/crush/build.go | 179 ++++ testdata/crush/crush.db | Bin 0 -> 24576 bytes 22 files changed, 5787 insertions(+), 7 deletions(-) create mode 100644 internal/adapter/crush/adapter.go create mode 100644 internal/adapter/crush/adapter_test.go create mode 100644 internal/adapter/crush/agents.go create mode 100644 internal/adapter/crush/agents_test.go create mode 100644 internal/adapter/crush/diagnostics.go create mode 100644 internal/adapter/crush/diagnostics_test.go create mode 100644 internal/adapter/crush/fixture_test.go create mode 100644 internal/adapter/crush/fuzz_test.go create mode 100644 internal/adapter/crush/handle.go create mode 100644 internal/adapter/crush/parts.go create mode 100644 internal/adapter/crush/parts_test.go create mode 100644 internal/adapter/crush/projects.go create mode 100644 internal/adapter/crush/sessions.go create mode 100644 internal/adapter/crush/sessions_test.go create mode 100644 internal/adapter/crush/stress_test.go create mode 100644 internal/adapter/crush/todo_spike_test.go create mode 100644 testdata/crush/build.go create mode 100644 testdata/crush/crush.db diff --git a/.gitignore b/.gitignore index 697fc2e..264a9f3 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,5 @@ yarn-debug.log* yarn-error.log* web/dist/ web/node_modules/ +*.db-shm +*.db-wal diff --git a/go.mod b/go.mod index 6ce9903..97b6109 100644 --- a/go.mod +++ b/go.mod @@ -2,9 +2,21 @@ module github.com/cosmtrek/mindwalk go 1.26.5 -require github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 +require ( + github.com/LarsArtmann/go-crush-data v0.3.0 + github.com/dustin/go-humanize v1.0.1 + github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 + modernc.org/sqlite v1.56.0 +) require ( - github.com/dustin/go-humanize v1.0.1 // indirect - golang.org/x/text v0.14.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/mattn/go-isatty v0.0.24 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.41.0 // indirect + modernc.org/libc v1.75.3 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.12.0 // indirect ) diff --git a/go.sum b/go.sum index f19ed67..a080d0d 100644 --- a/go.sum +++ b/go.sum @@ -1,8 +1,58 @@ -github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= -github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/LarsArtmann/go-crush-data v0.3.0 h1:E/IQXGByg3gz8kiFmEvhyAtTcnJy6/tGidfYDFMkUNU= +github.com/LarsArtmann/go-crush-data v0.3.0/go.mod h1:SXis5Wdqi+DgpdQ2+IyMRho8hXMlpBYRDHrCcAo8EFk= +github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8= +github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= +github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= -golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= -golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= +golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= +golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= +golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= +modernc.org/cc/v4 v4.29.2 h1:h6+9ciCnPKutf4I03CvheAvDLX7+IHlqR6Iy6J+cgd8= +modernc.org/cc/v4 v4.29.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= +modernc.org/ccgo/v4 v4.35.0 h1:F+TUsmw09QxLzmi3aeYYGxjAXarmZaKgj3mKQHNaA8w= +modernc.org/ccgo/v4 v4.35.0/go.mod h1:qrVGs9S3Sr2Ztcg9ve+kTAYMp5a3YvWjo+SoN06kJ5I= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.5 h1:21ldfPfRYE31Tb7B3mwAK8gy1AxP4+dKjrOQPfqakoc= +modernc.org/gc/v3 v3.1.5/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.75.3 h1:vCqT5+R0jPXMnvMkGo0T2zXvFNth+lYXVCx5X7CCX/g= +modernc.org/libc v1.75.3/go.mod h1:MjAX68G+0oufI+hNuh0QXcK+Ap+sL8bNPPcIC6EqOfo= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.12.0 h1:twkmYNkGXCvtYWzoux02jtK6eovjZbdI0uHFUYp6kuU= +modernc.org/memory v1.12.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= +modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.56.0 h1:/D8e2RfFqoy/Zc6PuC76U28zFwmI/sYx1Kjm4yEn9e0= +modernc.org/sqlite v1.56.0/go.mod h1:yCJ2cmAaIkHQ25oXWrF8H4O1lIfPYPR26yCEDj2P3pQ= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/internal/adapter/crush/adapter.go b/internal/adapter/crush/adapter.go new file mode 100644 index 0000000..f6bb318 --- /dev/null +++ b/internal/adapter/crush/adapter.go @@ -0,0 +1,302 @@ +// Package crush adapts charmbracelet/crush sessions to the shared +// model.Trace contract. Crush stores every session inside a single +// SQLite database (crush.db) under a per-project data directory. +// +// The adapter is read-only and shares the data dir with a running +// crush TUI: it opens the database in mode=ro without acquiring the +// data-dir lock and never writes. Each session's tool calls live in +// one row per message, with parts JSON-encoded in messages.parts and +// ordered by created_at (second-precision Unix timestamps). +package crush + +import ( + "errors" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "sync" + + crushdata "github.com/LarsArtmann/go-crush-data" +) + +const ( + // dataDirName is the conventional Crush project data directory + // (the schema default in upstream crush). + dataDirName = ".crush" + + // dbName is the SQLite filename Crush uses for every data dir. + dbName = "crush.db" + + // appName is the XDG application identifier Crush uses for global + // data and config directories. + appName = "crush" +) + +type Adapter struct { + // Dir overrides the Crush data directory. When empty the adapter + // auto-discovers one by walking upward from the current working + // directory looking for a .crush/crush.db (bounded by the git + // worktree root) and then falls back to CRUSH_GLOBAL_DATA, + // XDG_DATA_HOME, and ~/.local/share/crush. + Dir string + + // WorkingDir is the directory used to discover a project-local + // .crush directory. Empty defaults to os.Getwd. Tests inject a + // temp directory so they never read the host filesystem. + WorkingDir string + + // dbIndex maps session IDs to their crush.db filesystem path, + // populated during ListSessions so Parse/Summarize can open the + // right database. Nil when the adapter was constructed without + // NewAdapter (e.g. Adapter{Dir: dir}); in that case routing falls + // back to the single resolved database. + dbIndex *sync.Map + + // dbCache caches open *crushdata.DB handles by database path so a + // long-lived server does not re-open the SQLite file on every + // Parse/Summarize call. Nil when the adapter was constructed + // without NewAdapter; in that case every call opens and closes its + // own handle. + dbCache *sync.Map + + // projects caches the projects.json registry so projectPathForDB + // can resolve a database path to a project working directory without + // re-reading the registry on every call. Nil for zero-value + // Adapters; they fall back to path inference. + projects *projectPathStore + + // warnedOldSchema deduplicates schema warnings so each database + // path is warned about at most once per adapter instance. + warnedOldSchema *sync.Map +} + +// NewAdapter creates an Adapter with its own session-to-DB index and +// database connection cache, isolating it from other Adapter instances. +// Use this in the server and in tests that need to control multi-database +// routing without sharing global state. +func NewAdapter(dir string) Adapter { + return Adapter{ + Dir: dir, + dbIndex: &sync.Map{}, + dbCache: &sync.Map{}, + projects: &projectPathStore{}, + warnedOldSchema: &sync.Map{}, + } +} + +// Close releases cached database connections. Safe to call multiple +// times; adapters constructed without NewAdapter (nil dbCache) are a +// no-op. +func (a Adapter) Close() error { + if a.dbCache == nil { + return nil + } + + var firstErr error + + a.dbCache.Range(func(_, value any) bool { + if db, ok := value.(*crushdata.DB); ok && db != nil { + if err := db.Close(); err != nil && firstErr == nil { + firstErr = err + } + } + + return true + }) + + return firstErr +} + +// DefaultDir returns the platform-specific global Crush data directory: +// CRUSH_GLOBAL_DATA when set, otherwise $XDG_DATA_HOME/crush (Unix) or +// %LOCALAPPDATA%\crush (Windows), otherwise ~/.local/share/crush. +// +// The global directory holds crush.json (machine state); the database +// crush.db lives next to it. Empty when the home directory cannot be +// resolved. +func DefaultDir() string { + if env := os.Getenv("CRUSH_GLOBAL_DATA"); env != "" { + return env + } + + if xdg := os.Getenv("XDG_DATA_HOME"); xdg != "" { + return filepath.Join(xdg, appName) + } + + if runtime.GOOS == "windows" { + local := os.Getenv("LOCALAPPDATA") + if local == "" { + if home := os.Getenv("USERPROFILE"); home != "" { + local = filepath.Join(home, "AppData", "Local") + } + } + + if local != "" { + return filepath.Join(local, appName) + } + } + + if home, err := os.UserHomeDir(); err == nil { + return filepath.Join(home, ".local", "share", appName) + } + + return "" +} + +// Harness returns the canonical harness identifier the rest of +// mindwalk uses to filter traces and pick the right adapter. +func (a Adapter) Harness() string { return "crush" } + +// SessionDir returns the resolved Crush data directory the +// adapter will use to look up sessions. It returns the explicit +// override first, then the project-local .crush (walking up from +// the working dir, bounded by the git worktree root), then the +// global directory. The returned path may not exist on disk; +// callers should fall back gracefully. +func (a Adapter) SessionDir() string { + if a.Dir != "" { + return a.Dir + } + + workdir := a.WorkingDir + if workdir == "" { + if cwd, err := os.Getwd(); err == nil { + workdir = cwd + } + } + + if workdir != "" { + if resolved := lookupProjectDataDir(workdir); resolved != "" { + return resolved + } + } + + return DefaultDir() +} + +// dbPath returns the crush.db path under the resolved data dir, or "" +// when no data dir can be resolved. +func (a Adapter) dbPath() string { + dir := a.SessionDir() + if dir == "" { + return "" + } + + return filepath.Join(dir, dbName) +} + +// lookupProjectDataDir walks from dir upward looking for a .crush +// directory, stopping at the git worktree root when one is detected so +// we never pick up a stranger's Crush database above the project. The +// returned path is absolute and points at the data directory itself +// (the directory that contains crush.db), not the database file. +func lookupProjectDataDir(dir string) string { + if dir == "" { + return "" + } + + boundary := projectBoundary(dir) + + current := dir + for { + candidate := filepath.Join(current, dataDirName) + if isCrushDataDir(candidate) { + return candidate + } + + parent := filepath.Dir(current) + if parent == current || (boundary != "" && current == boundary) { + return "" + } + + current = parent + } +} + +// isCrushDataDir reports whether dir is a Crush data directory — a +// directory containing crush.db. The bare .crush project directory +// may exist with no database; ignore it so we don't misidentify an +// unrelated project. +func isCrushDataDir(dir string) bool { + if dir == "" { + return false + } + + info, err := os.Stat(filepath.Join(dir, dbName)) + + return err == nil && !info.IsDir() +} + +// projectBoundary returns the directory at which an upward +// configuration search rooted at dir should stop — the git worktree +// root when one can be detected, otherwise the directory itself. The +// boundary keeps Crush from silently adopting state files placed above +// the current project. +func projectBoundary(dir string) string { + if dir == "" { + return "" + } + + if root := worktreeRoot(dir); root != "" { + return root + } + + abs, err := filepath.Abs(dir) + if err != nil { + return dir + } + + return abs +} + +// worktreeRootCache memoizes the git worktree root per directory. The +// root is stable for the life of the process, so re-shelling out to +// `git rev-parse` on every adapter query would be wasteful. Keyed by +// the requested dir; the value is the resolved root ("" when dir is +// not in a git worktree). +var worktreeRootCache sync.Map + +// worktreeRoot returns the absolute path of the git working tree root +// for dir, or "" when dir is not inside a working tree (bare +// repositories, missing git binary, plain directories, or any other +// failure mode). +func worktreeRoot(dir string) string { + if v, ok := worktreeRootCache.Load(dir); ok { + if s, ok := v.(string); ok { + return s + } + } + + root := computeWorktreeRoot(dir) + worktreeRootCache.Store(dir, root) + + return root +} + +func computeWorktreeRoot(dir string) string { + cmd := exec.Command("git", "rev-parse", "--show-toplevel") + cmd.Dir = dir + + out, err := cmd.Output() + if err != nil { + return "" + } + + root := strings.TrimSpace(string(out)) + if root == "" { + return "" + } + + if abs, err := filepath.Abs(root); err == nil { + return abs + } + + return root +} + +// errDBUnavailable is returned by Parse/Summarize when the database +// is missing or unreadable. Callers fall back to "no sessions" when +// the same condition is observed during ListSessions. +var errDBUnavailable = errors.New("crush database not available") diff --git a/internal/adapter/crush/adapter_test.go b/internal/adapter/crush/adapter_test.go new file mode 100644 index 0000000..d0e4be2 --- /dev/null +++ b/internal/adapter/crush/adapter_test.go @@ -0,0 +1,1039 @@ +package crush + +import ( + "database/sql" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/cosmtrek/mindwalk/internal/model" + _ "modernc.org/sqlite" +) + +// newFixtureDB creates a fresh Crush database under t.TempDir/crush.db +// and seeds it with the minimum schema the adapter reads. Every test +// returns the open handle plus the working directory so adapters +// can resolve a project-local .crush without touching the host FS. +func newFixtureDB(t *testing.T, seed func(*sql.DB)) (dir string, db *sql.DB) { + t.Helper() + root := t.TempDir() + + data := filepath.Join(root, ".crush") + if err := os.MkdirAll(data, 0o755); err != nil { + t.Fatal(err) + } + + handle, err := sql.Open("sqlite", "file:"+filepath.Join(data, "crush.db")+"?_pragma=journal_mode(WAL)") + if err != nil { + t.Fatal(err) + } + + handle.SetMaxOpenConns(1) + + schema := ` + CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, + parent_session_id TEXT, + title TEXT NOT NULL, + message_count INTEGER NOT NULL DEFAULT 0, + prompt_tokens INTEGER NOT NULL DEFAULT 0, + completion_tokens INTEGER NOT NULL DEFAULT 0, + cost REAL NOT NULL DEFAULT 0.0, + updated_at INTEGER NOT NULL, + created_at INTEGER NOT NULL, + todos TEXT + ); + CREATE TABLE IF NOT EXISTS messages ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + role TEXT NOT NULL, + parts TEXT NOT NULL DEFAULT '[]', + model TEXT, + provider TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + finished_at INTEGER + ); + ` + if _, err := handle.Exec(schema); err != nil { + _ = handle.Close() + + t.Fatal(err) + } + + if seed != nil { + seed(handle) + } + + t.Cleanup(func() { _ = handle.Close() }) + + return data, handle +} + +func writeParts(t *testing.T, parts ...map[string]any) string { + t.Helper() + + encoded := make([]json.RawMessage, 0, len(parts)) + for _, part := range parts { + data, err := json.Marshal(part) + if err != nil { + t.Fatal(err) + } + + encoded = append(encoded, data) + } + + out, err := json.Marshal(encoded) + if err != nil { + t.Fatal(err) + } + + return string(out) +} + +// insertSession writes a row to the sessions table. createdAt is +// captured in Unix seconds like Crush does internally. +func insertSession( + t *testing.T, + db *sql.DB, + id string, + parent string, + title string, + createdAt time.Time, + messages int64, +) { + t.Helper() + + if _, err := db.Exec( + `INSERT INTO sessions (id, parent_session_id, title, message_count, prompt_tokens, completion_tokens, cost, updated_at, created_at) VALUES (?, ?, ?, ?, 0, 0, 0.0, ?, ?)`, + id, + nullableString(parent), + title, + messages, + createdAt.Unix(), + createdAt.Unix(), + ); err != nil { + t.Fatal(err) + } +} + +// insertMessage writes one row to the messages table. createdAt is +// recorded in Unix seconds to match Crush's convention. +func insertMessage( + t *testing.T, + db *sql.DB, + sessionID string, + id string, + role string, + parts string, + model string, + createdAt time.Time, +) { + t.Helper() + + if _, err := db.Exec( + `INSERT INTO messages (id, session_id, role, parts, model, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)`, + id, + sessionID, + role, + parts, + nullableString(model), + createdAt.Unix(), + createdAt.Unix(), + ); err != nil { + t.Fatal(err) + } +} + +func nullableString(value string) any { + if value == "" { + return nil + } + + return value +} + +// TestSessionDirPrefersProjectFixture verifies the adapter's project +// discovery: a .crush directory inside the working dir wins over the +// platform default even when CRUSH_GLOBAL_DATA points elsewhere. +func TestSessionDirPrefersProjectFixture(t *testing.T) { + root := t.TempDir() + project := filepath.Join(root, "project") + + data := filepath.Join(project, ".crush") + if err := os.MkdirAll(data, 0o755); err != nil { + t.Fatal(err) + } + // The directory only counts as a Crush data dir when crush.db + // sits next to it — create an empty file so the lookup accepts + // the project fixture. + if err := os.WriteFile(filepath.Join(data, "crush.db"), nil, 0o644); err != nil { + t.Fatal(err) + } + // Truncate any cached git-worktree root from previous runs so + // the fresh temp directory is not confused with one upstream. + worktreeRootCache.Clear() + t.Setenv("CRUSH_GLOBAL_DATA", filepath.Join(root, "global")) + + got := Adapter{WorkingDir: project}.SessionDir() + if got != data { + t.Fatalf("session dir = %q, want %q", got, data) + } +} + +// TestListSessionsHidesAgentSessions ensures that agent-tool child +// sessions (those with a parent_session_id) stay out of the rail. +func TestListSessionsHidesAgentSessions(t *testing.T) { + data, db := newFixtureDB(t, nil) + base := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC) + insertSession(t, db, "root-1", "", "Root session", base, 4) + insertSession(t, db, "child-1", "root-1", "Agent: explore", base.Add(time.Minute), 2) + + adapter := Adapter{Dir: data} + + metas, err := adapter.ListSessions() + if err != nil { + t.Fatal(err) + } + + if len(metas) != 1 { + t.Fatalf("metas = %d, want 1 (child sessions should be hidden)", len(metas)) + } + + if metas[0].ID != "root-1" { + t.Fatalf("meta id = %q", metas[0].ID) + } +} + +// TestParseBuildsEventsAndMarks covers the full Parse path: text +// concatenation across messages, tool_call/tool_result pairing, and +// the user-message mark for a finish-reason=stop turn. +func TestParseBuildsEventsAndMarks(t *testing.T) { + data, db := newFixtureDB(t, nil) + base := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC) + insertSession(t, db, "demo", "", "Demo", base, 3) + + // User message carrying the request. + insertMessage(t, db, "demo", "m1", "user", writeParts(t, + map[string]any{"type": "text", "data": map[string]any{"text": "Add crush support"}}, + map[string]any{"type": "finish", "data": map[string]any{"reason": "stop", "time": 0}}, + ), "", base) + + // Assistant message with a view tool call. + insertMessage(t, db, "demo", "m2", "assistant", writeParts( + t, + map[string]any{"type": "text", "data": map[string]any{"text": "Looking at the adapter."}}, + map[string]any{ + "type": "tool_call", + "data": map[string]any{ + "id": "call_view_1", + "name": "view", + "input": `{"file_path":"internal/adapter/adapter.go"}`, + "finished": true, + "provider_executed": false, + }, + }, + ), "minimax/minimax-m3", base.Add(time.Second)) + + // Tool result message. + insertMessage(t, db, "demo", "m3", "tool", writeParts( + t, + map[string]any{ + "type": "tool_result", + "data": map[string]any{ + "tool_call_id": "call_view_1", + "name": "view", + "content": "package adapter\n", + "is_error": false, + }, + }, + ), "", base.Add(2*time.Second)) + + adapter := Adapter{Dir: data} + + trace, err := adapter.Parse(SessionPath("demo")) + if err != nil { + t.Fatal(err) + } + + if trace.Session.Harness != "crush" { + t.Fatalf("harness = %q", trace.Session.Harness) + } + + if trace.Session.Model != "minimax/minimax-m3" { + t.Fatalf("model = %q", trace.Session.Model) + } + + if len(trace.Events) != 1 { + t.Fatalf("events = %d, want 1", len(trace.Events)) + } + + ev := trace.Events[0] + if ev.Tool != "view" || ev.Action != "read" { + t.Fatalf("event = %+v", ev) + } + + if len(ev.Targets) != 1 || ev.Targets[0].Path != "internal/adapter/adapter.go" { + t.Fatalf("targets = %+v", ev.Targets) + } + + if len(trace.Marks) != 1 || trace.Marks[0].Type != "user-message" { + t.Fatalf("marks = %+v", trace.Marks) + } +} + +// TestParseOrphanToolCallStillEmitsEvent ensures a tool_call without +// a matching tool_result still surfaces an event so the timeline +// doesn't silently lose tool attempts. +func TestParseOrphanToolCallStillEmitsEvent(t *testing.T) { + data, db := newFixtureDB(t, nil) + base := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC) + insertSession(t, db, "demo", "", "Orphan", base, 1) + insertMessage(t, db, "demo", "m1", "assistant", writeParts( + t, + map[string]any{ + "type": "tool_call", + "data": map[string]any{ + "id": "missing", + "name": "bash", + "input": `{"command":"go test"}`, + "finished": true, + "provider_executed": false, + }, + }, + ), "", base) + + trace, err := Adapter{Dir: data}.Parse(SessionPath("demo")) + if err != nil { + t.Fatal(err) + } + + if len(trace.Events) != 1 { + t.Fatalf("events = %d, want 1", len(trace.Events)) + } + + if trace.Events[0].ResultBytes != 0 { + t.Fatalf("orphan event should carry an empty result, got %d bytes", trace.Events[0].ResultBytes) + } +} + +// TestParseMarksSubagentLaunches ensures the `agent` tool emits a +// subagent mark so the trace aligns with the Agent Lens graph. +func TestParseMarksSubagentLaunches(t *testing.T) { + data, db := newFixtureDB(t, nil) + base := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC) + insertSession(t, db, "root", "", "Root", base, 1) + insertMessage(t, db, "root", "m1", "assistant", writeParts( + t, + map[string]any{ + "type": "tool_call", + "data": map[string]any{ + "id": "agent_1", + "name": "agent", + "input": `{"task_title":"explore schema","agent_type":"explore","message":"read the migrations"}`, + "finished": true, + "provider_executed": false, + }, + }, + ), "", base) + + trace, err := Adapter{Dir: data}.Parse(SessionPath("root")) + if err != nil { + t.Fatal(err) + } + + if len(trace.Marks) != 1 { + t.Fatalf("marks = %+v", trace.Marks) + } + + if trace.Marks[0].Type != "subagent" { + t.Fatalf("mark type = %q", trace.Marks[0].Type) + } + + if trace.Marks[0].Note != "explore schema" { + t.Fatalf("note = %q", trace.Marks[0].Note) + } +} + +// TestParseHandlesNestedJSONInput guards against the common Crush +// shape where tool_call.input is a stringified JSON literal of a +// stringified JSON object. The parser must peel both layers. +func TestParseHandlesNestedJSONInput(t *testing.T) { + data, db := newFixtureDB(t, nil) + base := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC) + insertSession(t, db, "demo", "", "Nested", base, 1) + // inner JSON literal — what Crush often writes for tool inputs. + nested := `{"file_path":"internal/server/server.go","limit":50}` + insertMessage(t, db, "demo", "m1", "assistant", writeParts( + t, + map[string]any{ + "type": "tool_call", + "data": map[string]any{ + "id": "call_view_1", + "name": "view", + "input": nested, + "finished": true, + "provider_executed": false, + }, + }, + ), "", base) + + trace, err := Adapter{Dir: data}.Parse(SessionPath("demo")) + if err != nil { + t.Fatal(err) + } + + if len(trace.Events) != 1 { + t.Fatalf("events = %d, want 1", len(trace.Events)) + } + + if trace.Events[0].Targets[0].Path != "internal/server/server.go" { + t.Fatalf("path = %q", trace.Events[0].Targets[0].Path) + } +} + +// TestParseRelativizesAbsolutePathsFromCwd is the regression test for the +// root cause of the "everything is unvisited" bug: Crush tool calls use +// absolute file paths, but without trace.Session.Cwd the normalizer +// classifies every path as outside the repo and no target is emitted. +// The fixture lives under /.crush/crush.db, so projectPathForDB +// derives as the project root and Parse must set it as the cwd +// before BuildEvent runs. +func TestParseRelativizesAbsolutePathsFromCwd(t *testing.T) { + data, db := newFixtureDB(t, nil) + // data is /.crush — projectPathForDB should derive . + wantCwd := filepath.Dir(data) + base := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC) + insertSession(t, db, "demo", "", "Demo", base, 1) + + absPath := filepath.Join(wantCwd, "internal", "server", "server.go") + insertMessage(t, db, "demo", "m1", "assistant", writeParts( + t, + map[string]any{ + "type": "tool_call", + "data": map[string]any{ + "id": "call_view_1", + "name": "view", + "input": `{"file_path":"` + absPath + `"}`, + "finished": true, + "provider_executed": false, + }, + }, + ), "", base) + + trace, err := Adapter{Dir: data}.Parse(SessionPath("demo")) + if err != nil { + t.Fatal(err) + } + + if trace.Session.Cwd != wantCwd { + t.Fatalf("cwd = %q, want %q", trace.Session.Cwd, wantCwd) + } + + if len(trace.Events) != 1 { + t.Fatalf("events = %d, want 1", len(trace.Events)) + } + + ev := trace.Events[0] + if len(ev.Targets) != 1 { + t.Fatalf("targets = %+v (want 1)", ev.Targets) + } + + wantRel := filepath.ToSlash(filepath.Join("internal", "server", "server.go")) + if ev.Targets[0].Path != wantRel { + t.Fatalf("target path = %q, want %q", ev.Targets[0].Path, wantRel) + } + + if len(ev.Outside) != 0 { + t.Fatalf("outside = %+v (want none)", ev.Outside) + } +} + +// TestProjectPathForDBDerivation verifies the path-based fallback: +// /.crush/crush.db resolves to , while databases not +// under a .crush directory resolve to "". +func TestProjectPathForDBDerivation(t *testing.T) { + // Ensure the projects.json cache doesn't interfere — point the + // global data dir at an empty temp dir so loadProjectDBs returns nil. + empty := t.TempDir() + t.Setenv("CRUSH_GLOBAL_DATA", empty) + + tmp := t.TempDir() + crushDB := filepath.Join(tmp, dataDirName, dbName) + + got := Adapter{}.projectPathForDB(crushDB) + if got != tmp { + t.Fatalf("projectPathForDB(%q) = %q, want %q", crushDB, got, tmp) + } + + // Non-.crush directory → empty. + plain := filepath.Join(t.TempDir(), "data", dbName) + if got := (Adapter{}).projectPathForDB(plain); got != "" { + t.Fatalf("projectPathForDB(%q) = %q, want empty", plain, got) + } + + // Empty input → empty. + if got := (Adapter{}).projectPathForDB(""); got != "" { + t.Fatalf("projectPathForDB(\"\") = %q, want empty", got) + } +} + +// TestSummarizeFlagsAuxiliary verifies the Summarize path stamps +// Auxiliary=true on agent-tool child sessions and exposes both the +// message id and launching tool call id on Agent. +func TestSummarizeFlagsAuxiliary(t *testing.T) { + data, db := newFixtureDB(t, nil) + base := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC) + insertSession(t, db, "root", "", "Root", base, 1) + insertSession(t, db, "msg-1$$toolcall-1", "root", "Agent: explore", base.Add(time.Minute), 2) + + adapter := Adapter{Dir: data} + + meta, err := adapter.Summarize(SessionPath("msg-1$$toolcall-1")) + if err != nil { + t.Fatal(err) + } + + if !meta.Auxiliary { + t.Fatalf("meta auxiliary = false, want true") + } + + if meta.Agent == nil || meta.Agent.SourceID != "msg-1" || meta.Agent.LaunchCallID != "toolcall-1" { + t.Fatalf("agent = %+v", meta.Agent) + } +} + +// TestDecodePartsToleratesMalformedParts ensures a malformed parts +// payload doesn't poison the whole parse — the trace still loads. +func TestDecodePartsToleratesMalformedParts(t *testing.T) { + _, err := decodeParts("not json", "") + if err == nil { + t.Fatalf("expected decode error for non-JSON parts") + } + + result, err := decodeParts("[]", "") + if err != nil { + t.Fatal(err) + } + + if result.text != "" || len(result.events) != 0 { + t.Fatalf("empty parts should produce empty result, got %+v", result) + } +} + +// TestSummarizeMissingDatabase is the no-Crush-installed fallback — +// calling Summarize on an empty data dir must surface a clear error +// instead of returning a phantom meta. +func TestSummarizeMissingDatabase(t *testing.T) { + root := t.TempDir() + t.Setenv("CRUSH_GLOBAL_DATA", root) + // Wipe the global data directory so neither project nor global + // discovery can find a crush.db. The adapter should report + // "no Crush session" rather than fabricating one. + if err := os.RemoveAll(root); err != nil { + t.Fatal(err) + } + + _, err := Adapter{}.Summarize(SessionPath("anything")) + if err == nil || !strings.Contains(err.Error(), "not a Crush session") { + t.Fatalf("expected not-a-Crush-session error, got %v", err) + } +} + +// TestOpenReadOnlyReportsUnderlyingError verifies the error path +// surfaces the underlying cause and the resolved path so a user +// can diagnose a broken configuration without re-deriving the +// path. An empty file and a directory where crush.db is expected +// each produce a distinct, helpful error message. +func TestOpenReadOnlyReportsUnderlyingError(t *testing.T) { + // Empty file → "empty (size 0)" + dir := t.TempDir() + + emptyFile := filepath.Join(dir, "crush.db") + if err := os.WriteFile(emptyFile, nil, 0o644); err != nil { + t.Fatal(err) + } + + _, err := Adapter{Dir: dir}.ListSessions() + if err == nil || !strings.Contains(err.Error(), "empty (size 0)") { + t.Fatalf("empty file error = %v", err) + } + + // Directory where file is expected → "is a directory" + dirAsFile := filepath.Join(t.TempDir(), "crush.db") + if err := os.MkdirAll(dirAsFile, 0o755); err != nil { + t.Fatal(err) + } + + _, err = Adapter{Dir: filepath.Dir(dirAsFile)}.ListSessions() + if err == nil || !strings.Contains(err.Error(), "is a directory") { + t.Fatalf("directory-as-file error = %v", err) + } +} + +// insertMessageWithProvider writes one row to the messages table with +// the provider column set, for tests that verify provider tracking. +func insertMessageWithProvider( + t *testing.T, + db *sql.DB, + sessionID string, + id string, + role string, + parts string, + model string, + provider string, + createdAt time.Time, +) { + t.Helper() + + if _, err := db.Exec( + `INSERT INTO messages (id, session_id, role, parts, model, provider, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + id, + sessionID, + role, + parts, + nullableString(model), + nullableString(provider), + createdAt.Unix(), + createdAt.Unix(), + ); err != nil { + t.Fatal(err) + } +} + +// insertSessionWithUsage writes a session row with non-zero token and +// cost values, for tests that verify usage metadata. +func insertSessionWithUsage( + t *testing.T, + db *sql.DB, + id string, + title string, + createdAt time.Time, + promptTokens int64, + completionTokens int64, + cost float64, +) { + t.Helper() + + if _, err := db.Exec( + `INSERT INTO sessions (id, parent_session_id, title, message_count, prompt_tokens, completion_tokens, cost, updated_at, created_at) VALUES (?, NULL, ?, 0, ?, ?, ?, ?, ?)`, + id, + title, + promptTokens, + completionTokens, + cost, + createdAt.Unix(), + createdAt.Unix(), + ); err != nil { + t.Fatal(err) + } +} + +// TestParsePopulatesProvider verifies the provider field is populated +// from the first non-null provider in the messages table. +func TestParsePopulatesProvider(t *testing.T) { + data, db := newFixtureDB(t, nil) + base := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC) + insertSession(t, db, "s1", "", "Provider test", base, 2) + insertMessageWithProvider(t, db, "s1", "m1", "user", + writeParts(t, map[string]any{"type": "text", "data": map[string]any{"text": "hello"}}), + "", "anthropic", base) + insertMessageWithProvider(t, db, "s1", "m2", "assistant", + writeParts(t, map[string]any{"type": "text", "data": map[string]any{"text": "hi back"}}), + "claude-sonnet-4", "anthropic", base.Add(time.Second)) + + trace, err := Adapter{Dir: data}.Parse(SessionPath("s1")) + if err != nil { + t.Fatalf("Parse: %v", err) + } + + if trace.Session.Provider != "anthropic" { + t.Fatalf("provider = %q, want anthropic", trace.Session.Provider) + } + + if trace.Session.Model != "claude-sonnet-4" { + t.Fatalf("model = %q, want claude-sonnet-4", trace.Session.Model) + } +} + +// TestParseEmitsModelSwitchMark verifies a model-switch mark is +// emitted when the model changes mid-session. +func TestParseEmitsModelSwitchMark(t *testing.T) { + data, db := newFixtureDB(t, nil) + base := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC) + insertSession(t, db, "s1", "", "Switch test", base, 3) + insertMessageWithProvider(t, db, "s1", "m1", "assistant", + writeParts(t, map[string]any{"type": "text", "data": map[string]any{"text": "first"}}), + "model-a", "anthropic", base) + insertMessageWithProvider(t, db, "s1", "m2", "assistant", + writeParts(t, map[string]any{"type": "text", "data": map[string]any{"text": "second"}}), + "model-b", "anthropic", base.Add(time.Second)) + + trace, err := Adapter{Dir: data}.Parse(SessionPath("s1")) + if err != nil { + t.Fatalf("Parse: %v", err) + } + + var sawSwitch bool + + for _, m := range trace.Marks { + if m.Type == "model-switch" { + sawSwitch = true + + if !strings.Contains(m.Note, "model-a") || !strings.Contains(m.Note, "model-b") { + t.Fatalf("switch note = %q", m.Note) + } + } + } + + if !sawSwitch { + t.Fatalf("expected a model-switch mark, marks = %+v", trace.Marks) + } +} + +// TestReadFilesUpgradesObservability verifies the read_files table +// upgrades the read observability grade from estimated to exact. +func TestReadFilesUpgradesObservability(t *testing.T) { + data, db := newFixtureDB(t, func(db *sql.DB) { + if _, err := db.Exec( + `CREATE TABLE IF NOT EXISTS read_files (session_id TEXT NOT NULL, path TEXT NOT NULL, read_at INTEGER NOT NULL)`, + ); err != nil { + t.Fatal(err) + } + + if _, err := db.Exec( + `INSERT INTO read_files (session_id, path, read_at) VALUES ('s1', 'main.go', 0)`, + ); err != nil { + t.Fatal(err) + } + }) + base := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC) + insertSession(t, db, "s1", "", "Read files test", base, 1) + insertMessage(t, db, "s1", "m1", "assistant", + writeParts(t, map[string]any{"type": "text", "data": map[string]any{"text": "working"}}), + "claude-sonnet-4", base) + + trace, err := Adapter{Dir: data}.Parse(SessionPath("s1")) + if err != nil { + t.Fatalf("Parse: %v", err) + } + + if trace.Stats.Observability.Reads != model.ObservabilityExact { + t.Fatalf("reads observability = %q, want exact", trace.Stats.Observability.Reads) + } +} + +// TestReadFilesMissingTableFallsBack verifies that when the read_files +// table does not exist (older Crush databases), the observability +// stays at estimated without crashing. +func TestReadFilesMissingTableFallsBack(t *testing.T) { + data, db := newFixtureDB(t, nil) + base := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC) + insertSession(t, db, "s1", "", "No read_files", base, 1) + insertMessage(t, db, "s1", "m1", "assistant", + writeParts(t, map[string]any{"type": "text", "data": map[string]any{"text": "working"}}), + "claude-sonnet-4", base) + + trace, err := Adapter{Dir: data}.Parse(SessionPath("s1")) + if err != nil { + t.Fatalf("Parse: %v", err) + } + // No read events at all → unavailable; but the important thing is + // it doesn't crash. If there were reads, it would be estimated. + if trace.Stats.Observability.Reads == model.ObservabilityExact { + t.Fatalf("reads should not be exact without read_files table") + } +} + +// TestParseEmitsFinishReasonMarks verifies non-normal finish reasons +// (error, content_filter, canceled, max_tokens) produce finish-reason +// marks in the trace. +func TestParseEmitsFinishReasonMarks(t *testing.T) { + for _, reason := range []string{"error", "content_filter", "canceled", "max_tokens"} { + t.Run(reason, func(t *testing.T) { + data, db := newFixtureDB(t, nil) + base := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC) + insertSession(t, db, "s1", "", "Finish test", base, 1) + insertMessage(t, db, "s1", "m1", "assistant", + writeParts(t, + map[string]any{"type": "text", "data": map[string]any{"text": "trying"}}, + map[string]any{"type": "finish", "data": map[string]any{"reason": reason}}, + ), + "claude-sonnet-4", base) + + trace, err := Adapter{Dir: data}.Parse(SessionPath("s1")) + if err != nil { + t.Fatalf("Parse: %v", err) + } + + var saw bool + + for _, m := range trace.Marks { + if m.Type == "finish-reason" { + saw = true + + if !strings.Contains(m.Note, reason) { + t.Fatalf("note = %q, want to contain %q", m.Note, reason) + } + } + } + + if !saw { + t.Fatalf("expected finish-reason mark for %q, marks = %+v", reason, trace.Marks) + } + }) + } +} + +// TestParseEmitsThinkingMark verifies a reasoning part produces a +// thinking mark with the thinking text and duration. +func TestParseEmitsThinkingMark(t *testing.T) { + data, db := newFixtureDB(t, nil) + base := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC) + insertSession(t, db, "s1", "", "Thinking test", base, 1) + insertMessage(t, db, "s1", "m1", "assistant", + writeParts(t, + map[string]any{"type": "reasoning", "data": map[string]any{ + "thinking": "I should check the file first", + "started_at": 1000, + "finished_at": 1012, + }}, + map[string]any{"type": "text", "data": map[string]any{"text": "Let me check."}}, + ), + "claude-sonnet-4", base) + + trace, err := Adapter{Dir: data}.Parse(SessionPath("s1")) + if err != nil { + t.Fatalf("Parse: %v", err) + } + + var sawThinking bool + + for _, m := range trace.Marks { + if m.Type == "thinking" { + sawThinking = true + + if !strings.Contains(m.Note, "12s") { + t.Fatalf("thinking note should contain duration 12s, got %q", m.Note) + } + + if !strings.Contains(m.Note, "check the file") { + t.Fatalf("thinking note should contain text, got %q", m.Note) + } + } + } + + if !sawThinking { + t.Fatalf("expected a thinking mark, marks = %+v", trace.Marks) + } +} + +// TestListSessionsPopulatesUsageAndCost verifies the SessionMeta +// carries prompt_tokens, completion_tokens, and cost from the +// sessions table. +func TestListSessionsPopulatesUsageAndCost(t *testing.T) { + data, db := newFixtureDB(t, nil) + base := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC) + insertSessionWithUsage(t, db, "s1", "Usage test", base, 5000, 12000, 0.42) + + metas, err := Adapter{Dir: data}.ListSessions() + if err != nil { + t.Fatal(err) + } + + if len(metas) != 1 { + t.Fatalf("metas = %d, want 1", len(metas)) + } + + m := metas[0] + if m.PromptTokens != 5000 { + t.Fatalf("promptTokens = %d, want 5000", m.PromptTokens) + } + + if m.CompletionTokens != 12000 { + t.Fatalf("completionTokens = %d, want 12000", m.CompletionTokens) + } + + if m.Cost != 0.42 { + t.Fatalf("cost = %f, want 0.42", m.Cost) + } +} + +// createOldSchemaDB creates a Crush database with the pre-migration +// schema — no cost column on sessions, no finished_at on messages. +// This simulates a database from an older Crush version. +func createOldSchemaDB(t *testing.T, dbPath string) *sql.DB { + t.Helper() + + dir := filepath.Dir(dbPath) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + + handle, err := sql.Open("sqlite", "file:"+dbPath+"?_pragma=journal_mode(WAL)") + if err != nil { + t.Fatal(err) + } + + handle.SetMaxOpenConns(1) + + oldSchema := ` + CREATE TABLE sessions ( + id TEXT PRIMARY KEY, + parent_session_id TEXT, + title TEXT NOT NULL, + message_count INTEGER NOT NULL DEFAULT 0, + prompt_tokens INTEGER NOT NULL DEFAULT 0, + completion_tokens INTEGER NOT NULL DEFAULT 0, + updated_at INTEGER NOT NULL, + created_at INTEGER NOT NULL, + todos TEXT + ); + CREATE TABLE messages ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + role TEXT NOT NULL, + parts TEXT NOT NULL DEFAULT '[]', + model TEXT, + provider TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + ` + if _, err := handle.Exec(oldSchema); err != nil { + _ = handle.Close() + + t.Fatal(err) + } + + t.Cleanup(func() { _ = handle.Close() }) + + return handle +} + +// TestOldSchemaListSessionsDoesNotCrash verifies that a database +// without the cost column (pre-migration Crush) does not crash +// ListSessions. The dynamic query builder should substitute a +// literal zero for the missing column. +func TestOldSchemaListSessionsDoesNotCrash(t *testing.T) { + dir := t.TempDir() + dataDir := filepath.Join(dir, ".crush") + dbPath := filepath.Join(dataDir, dbName) + db := createOldSchemaDB(t, dbPath) + + base := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC) + if _, err := db.Exec( + `INSERT INTO sessions (id, title, message_count, prompt_tokens, completion_tokens, updated_at, created_at) VALUES (?, ?, 0, 0, 0, ?, ?)`, + "s1", + "Old schema", + base.Unix(), + base.Unix(), + ); err != nil { + t.Fatal(err) + } + + metas, err := Adapter{Dir: dataDir}.ListSessions() + if err != nil { + t.Fatalf("ListSessions on old schema should not error: %v", err) + } + + if len(metas) != 1 { + t.Fatalf("metas = %d, want 1", len(metas)) + } + + if metas[0].Cost != 0 { + t.Fatalf("cost should default to 0 on old schema, got %f", metas[0].Cost) + } +} + +// TestOldSchemaParseDoesNotCrash verifies that Parse works on a +// database without the finished_at column. +func TestOldSchemaParseDoesNotCrash(t *testing.T) { + dir := t.TempDir() + dataDir := filepath.Join(dir, ".crush") + dbPath := filepath.Join(dataDir, dbName) + db := createOldSchemaDB(t, dbPath) + + base := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC) + if _, err := db.Exec( + `INSERT INTO sessions (id, title, message_count, prompt_tokens, completion_tokens, updated_at, created_at) VALUES (?, ?, 1, 0, 0, ?, ?)`, + "s1", + "Old schema parse", + base.Unix(), + base.Unix(), + ); err != nil { + t.Fatal(err) + } + + if _, err := db.Exec( + `INSERT INTO messages (id, session_id, role, parts, model, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)`, + "m1", + "s1", + "assistant", + writeParts(t, map[string]any{"type": "text", "data": map[string]any{"text": "hello"}}), + "claude-sonnet-4", + base.Unix(), + base.Unix(), + ); err != nil { + t.Fatal(err) + } + + trace, err := Adapter{Dir: dataDir}.Parse(SessionPath("s1")) + if err != nil { + t.Fatalf("Parse on old schema should not error: %v", err) + } + + if trace.Session.ID != "s1" { + t.Fatalf("session id = %q", trace.Session.ID) + } +} + +// TestThinkingMarkUsesMessageDuration verifies that when a reasoning +// part lacks started_at/finished_at timestamps, the thinking mark +// falls back to the message-level finished_at - created_at duration. +func TestThinkingMarkUsesMessageDuration(t *testing.T) { + data, db := newFixtureDB(t, nil) + base := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC) + insertSession(t, db, "s1", "", "Thinking duration test", base, 1) + // Message with finished_at = created + 7 seconds + if _, err := db.Exec( + `INSERT INTO messages (id, session_id, role, parts, model, created_at, updated_at, finished_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + "m1", + "s1", + "assistant", + writeParts( + t, + map[string]any{"type": "reasoning", "data": map[string]any{"thinking": "I need to think about this"}}, + ), + "claude-sonnet-4", + base.Unix(), + base.Unix(), + base.Add(7*time.Second).Unix(), + ); err != nil { + t.Fatal(err) + } + + trace, err := Adapter{Dir: data}.Parse(SessionPath("s1")) + if err != nil { + t.Fatalf("Parse: %v", err) + } + + var sawThinking bool + + for _, m := range trace.Marks { + if m.Type == "thinking" { + sawThinking = true + + if !strings.Contains(m.Note, "7s") { + t.Fatalf("thinking note should contain duration 7s (from finished_at), got %q", m.Note) + } + } + } + + if !sawThinking { + t.Fatalf("expected a thinking mark, marks = %+v", trace.Marks) + } +} diff --git a/internal/adapter/crush/agents.go b/internal/adapter/crush/agents.go new file mode 100644 index 0000000..9c03443 --- /dev/null +++ b/internal/adapter/crush/agents.go @@ -0,0 +1,464 @@ +package crush + +import ( + "context" + "encoding/json" + "fmt" + "sort" + "strings" + + crushdata "github.com/LarsArtmann/go-crush-data" + "github.com/cosmtrek/mindwalk/internal/adapter" + "github.com/cosmtrek/mindwalk/internal/model" +) + +// Crush's agent tool launches a child session whose id is the parent +// message id joined with the launching tool call id via "$$" — the +// same shape `internal/session/session.go` documents upstream. Reading +// the parts JSON for a single message yields every agent launch the +// assistant performed during that turn; the matching child row in the +// sessions table is then read by SourceID/LaunchCallID. + +type crushGraphActor = adapter.GraphActor + +func (a Adapter) AgentGraphInputs(root model.SessionMeta, catalog []model.SessionMeta) ([]string, error) { + // The Crush adapter stores every trace in a database, so + // the agent graph only needs each session's path. Auxiliary + // children are intentionally hidden from the rail (ListSessions + // filters them out by parent_session_id IS NULL) but the agent + // graph builder needs them so it can match launches ↔ + // children. Query the catalog first, then fall back to every + // known database for any harness that didn't surface its + // sub-sessions to the catalog. + paths := map[string]bool{root.Path: true} + for _, session := range catalog { + if session.Harness != harnessName || session.Agent == nil || session.Path == "" { + continue + } + + paths[session.Path] = true + } + + for _, dbPath := range a.enumerateDBPaths() { + db, err := openAt(dbPath) + if err != nil || db == nil { + continue + } + + sessions, err := db.inner.Sessions(context.Background(), crushdata.SessionFilter{}) + if err == nil { + for _, cs := range sessions { + if meta := sessionMeta(cs); meta.Agent != nil { + paths[meta.Path] = true + } + } + } + + _ = db.close() + } + + inputs := make([]string, 0, len(paths)) + for path := range paths { + inputs = append(inputs, path) + } + + sort.Strings(inputs) + + return inputs, nil +} + +// BuildAgentGraph stitches the agent-tool launches recorded in the +// root session's messages to the child sessions present in the +// catalog. Like the codex implementation it tolerates orphans in +// both directions — a launch with no matching child and a child +// with no recorded launch — and renders each into the graph at the +// appropriate quality tier. +// +// Auxiliary children are hidden from the rail (ListSessions filters +// them out), so the catalog handed in by the server does not contain +// them. The builder supplements the catalog with any agent-tool +// sessions present in the same database. +func (a Adapter) BuildAgentGraph(root model.SessionMeta, catalog []model.SessionMeta) (*model.AgentGraph, error) { + mainID := adapter.AgentNodeID(harnessName, root.Key, "root:"+root.Key) + graph := &model.AgentGraph{ + Version: model.AgentGraphVersion, + RootSessionKey: root.Key, + Agents: []model.AgentNode{{ + ID: mainID, + Depth: 0, + Kind: model.AgentKindMain, + Label: "Main", + Status: model.AgentStatusMain, + TraceAvailability: model.TraceAvailabilityAvailable, + TraceSessionKey: root.Key, + TraceEventCount: root.EventCount, + LinkQuality: model.AgentLinkQualityExact, + LinkMethod: model.AgentLinkMethodRoot, + }}, + } + + childrenByParent := indexChildrenByParent(catalog) + + for _, child := range a.loadAgentChildren() { + childrenByParent[child.Agent.RootSessionID] = append(childrenByParent[child.Agent.RootSessionID], child) + } + + launches, err := a.readAgentLaunches(root.Path) + if err != nil { + return nil, fmt.Errorf("read agent launches from %s: %w", root.Path, err) + } + + addedNodes := map[string]bool{mainID: true} + visitedSessions := map[string]bool{root.Key: true} + + queue := []crushGraphActor{{Session: root, NodeID: mainID, SourceID: root.ID}} + for len(queue) > 0 { + actor := queue[0] + queue = queue[1:] + + linkedChildren := make(map[string]bool) + + for _, launch := range launches { + output, hasIdentity := parseCrushLaunchOutput(launch.Output) + if hasIdentity && output.AgentID != "" { + var child *model.SessionMeta + + for i := range childrenByParent[actor.SourceID] { + candidate := &childrenByParent[actor.SourceID][i] + if candidate.Agent != nil && candidate.Agent.SourceID == output.AgentID && + !visitedSessions[candidate.Key] { + child = candidate + + break + } + } + + node := exactCrushAgentNode(harnessName, root.Key, actor, launch, output, child) + if !addedNodes[node.ID] { + graph.Agents = append(graph.Agents, node) + addedNodes[node.ID] = true + } + + if child != nil { + linkedChildren[child.Key] = true + visitedSessions[child.Key] = true + queue = append(queue, crushGraphActor{ + Session: *child, + NodeID: node.ID, + Depth: node.Depth, + SourceID: child.Agent.SourceID, + }) + } + + continue + } + + node := unlinkedCrushLaunchNode(harnessName, root.Key, actor, launch) + if !addedNodes[node.ID] { + graph.Agents = append(graph.Agents, node) + addedNodes[node.ID] = true + } + } + + // Children recorded in the catalog but never matched to a + // launch are still surfaced as derived nodes — they almost + // always represent the same session launched via a path the + // trace didn't capture (e.g. the spawn happened before the + // cursor was saved). + for i := range childrenByParent[actor.SourceID] { + child := &childrenByParent[actor.SourceID][i] + if linkedChildren[child.Key] || visitedSessions[child.Key] { + continue + } + + node := derivedCrushAgentNode(harnessName, root.Key, actor, child) + if addedNodes[node.ID] { + continue + } + + graph.Agents = append(graph.Agents, node) + addedNodes[node.ID] = true + visitedSessions[child.Key] = true + queue = append(queue, crushGraphActor{ + Session: *child, + NodeID: node.ID, + Depth: node.Depth, + SourceID: child.Agent.SourceID, + }) + } + } + + graph.Agents = adapter.OrderAgentNodesPreorder(graph.Agents) + + return graph, nil +} + +// loadAgentChildren reads every auxiliary (agent-tool) session +// from every known database. ListSessions hides these from the rail, but +// the agent graph builder needs them so it can match launches to +// child rows. Databases that fail to open or answer are skipped — the +// catalog the caller passed in remains the source of truth. +func (a Adapter) loadAgentChildren() []model.SessionMeta { + var children []model.SessionMeta + + for _, dbPath := range a.enumerateDBPaths() { + db, err := openAt(dbPath) + if err != nil || db == nil { + continue + } + + sessions, err := db.inner.Sessions(context.Background(), crushdata.SessionFilter{}) + if err != nil { + _ = db.close() + + continue + } + + for _, cs := range sessions { + if meta := sessionMeta(cs); meta.Agent != nil { + children = append(children, meta) + } + } + + _ = db.close() + } + + return children +} + +// indexChildrenByParent groups the catalog's auxiliary sessions by the +// parent session id (the one stored in parent_session_id when the +// child row was created). The Crush schema records the parent session +// id directly, unlike codex, so no SourceID translation is required. +func indexChildrenByParent(catalog []model.SessionMeta) map[string][]model.SessionMeta { + childrenByParent := make(map[string][]model.SessionMeta) + + for _, session := range catalog { + if session.Agent == nil || session.Agent.RootSessionID == "" || session.Harness != harnessName { + continue + } + + childrenByParent[session.Agent.RootSessionID] = append(childrenByParent[session.Agent.RootSessionID], session) + } + + for parent := range childrenByParent { + sort.Slice(childrenByParent[parent], func(i, j int) bool { + return childrenByParent[parent][i].Key < childrenByParent[parent][j].Key + }) + } + + return childrenByParent +} + +// readAgentLaunches scans one session's messages for `agent` tool +// calls and their results. The agent tool is the only Crush-internal +// tool that spawns a child session, so anything else is ignored here. +// +// Tool calls and their results can land in different messages. The +// streaming parser keeps an observed-ids map across messages so a +// tool call in message A is paired with its result in message B. +func (a Adapter) readAgentLaunches(path string) ([]adapter.AgentLaunch, error) { + if path == "" { + return nil, nil + } + + sessionID, _, ok := splitSessionID(path) + if !ok { + return nil, nil + } + + db, err := a.openDBForPath(path) + if err != nil { + return nil, fmt.Errorf("open crush database for %s: %w", path, err) + } + + if db == nil { + return nil, nil + } + defer db.closeDiscard() + + launches := []adapter.AgentLaunch{} + launchByCallID := make(map[string]int) + seenCalls := make(map[string]bool) + seenResults := make(map[string]bool) + seq := 0 + + for msg, err := range db.inner.IterMessages(context.Background(), sessionID) { + if err != nil { + return nil, fmt.Errorf("read messages for session %s: %w", sessionID, err) + } + + parsed := foldParts(msg.Parts, "") + // Record each agent tool call (first occurrence wins). + // The events loop runs first so a tool result observed in + // the same message finds its corresponding launch. + for _, call := range parsed.events { + if call.Name != "agent" || call.ID == "" { + continue + } + + if seenCalls[call.ID] { + continue + } + + seenCalls[call.ID] = true + launchByCallID[call.ID] = len(launches) + launches = append(launches, adapter.AgentLaunch{ + CallID: call.ID, + Seq: seq, + Arguments: call.Input, + }) + seq++ + } + // Attach tool results to launches. Each result carries the + // originating tool_call_id, so we can match them across + // messages here. + for _, result := range parsed.results { + callID := result.ToolCallID + if callID == "" || seenResults[callID] { + continue + } + + seenResults[callID] = true + + idx, ok := launchByCallID[callID] + if !ok { + continue + } + + launches[idx].Output = result.Content + launches[idx].OutputObserved = true + } + } + + return launches, nil +} + +func parseCrushLaunchOutput(raw string) (adapter.AgentLaunchOutput, bool) { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return adapter.AgentLaunchOutput{}, false + } + + var output adapter.AgentLaunchOutput + if err := json.Unmarshal([]byte(trimmed), &output); err != nil { + return adapter.AgentLaunchOutput{}, false + } + + return output, output.AgentID != "" || output.Nickname != "" || output.TaskName != "" +} + +func exactCrushAgentNode( + harness, rootKey string, + actor crushGraphActor, + launch adapter.AgentLaunch, + output adapter.AgentLaunchOutput, + child *model.SessionMeta, +) model.AgentNode { + seq := launch.Seq + + node := model.AgentNode{ + ID: adapter.AgentNodeID(harness, rootKey, "crush-agent:"+output.AgentID), + ParentID: actor.NodeID, + Depth: actor.Depth + 1, + Kind: model.AgentKindSubagent, + Label: output.Nickname, + Role: agentArgument(launch.Arguments, "agent_type"), + InstructionPreview: adapter.AgentInstructionPreview(agentArgument(launch.Arguments, "message")), + LaunchSeq: &seq, + LaunchCallID: launch.CallID, + Status: model.AgentStatusLaunched, + TraceAvailability: model.TraceAvailabilityMissing, + LinkQuality: model.AgentLinkQualityExact, + LinkMethod: model.AgentLinkMethodCrushAgentID, + } + if child != nil { + node.TraceAvailability = model.TraceAvailabilityAvailable + node.TraceSessionKey = child.Key + + node.TraceEventCount = child.EventCount + if child.Agent != nil { + node.Depth = crushChildDepth(child.Agent.Depth, actor.Depth) + } + + if child.Title != "" { + node.Label = child.Title + } + } + + adapter.ApplyLaunchNickname(&node, output) + + return node +} + +func unlinkedCrushLaunchNode( + harness, rootKey string, + actor crushGraphActor, + launch adapter.AgentLaunch, +) model.AgentNode { + seq := launch.Seq + + return model.AgentNode{ + ID: adapter.AgentNodeID(harness, rootKey, "crush-agent:"+launch.CallID), + ParentID: actor.NodeID, + Depth: actor.Depth + 1, + Kind: model.AgentKindSubagent, + Label: launch.CallID, + Role: agentArgument(launch.Arguments, "agent_type"), + InstructionPreview: adapter.AgentInstructionPreview(agentArgument(launch.Arguments, "message")), + LaunchSeq: &seq, + LaunchCallID: launch.CallID, + Status: adapter.UnlinkedLaunchStatus(launch), + TraceAvailability: model.TraceAvailabilityMissing, + LinkQuality: model.AgentLinkQualityUnavailable, + LinkMethod: model.AgentLinkMethodUnavailable, + } +} + +func derivedCrushAgentNode(harness, rootKey string, actor crushGraphActor, child *model.SessionMeta) model.AgentNode { + node := model.AgentNode{ + ID: adapter.AgentNodeID(harness, rootKey, "crush-child:"+child.Agent.SourceID), + ParentID: actor.NodeID, + Depth: actor.Depth + 1, + Kind: model.AgentKindSubagent, + Label: child.Title, + Role: child.Agent.Role, + Status: model.AgentStatusLaunched, + TraceAvailability: model.TraceAvailabilityAvailable, + TraceSessionKey: child.Key, + TraceEventCount: child.EventCount, + LinkQuality: model.AgentLinkQualityDerived, + LinkMethod: model.AgentLinkMethodCodexAgentID, + LaunchCallID: child.Agent.LaunchCallID, + } + adapter.ApplySubagentLabel(&node) + + return node +} + +// crushChildDepth uses the auxiliary row's recorded depth when one +// was set; otherwise the parent's depth + 1. Crush does not stamp a +// depth column, so we fall back to the conventional increment. +func crushChildDepth(recorded, parentDepth int) int { + if recorded > 0 { + return recorded + } + + return parentDepth + 1 +} + +// agentArgument returns the first non-empty string field from input +// using the conventional Crush naming (prompt/task_title/...). +func agentArgument(input map[string]any, key string) string { + if v, ok := input[key].(string); ok { + return strings.TrimSpace(v) + } + + for _, alias := range []string{"task_title", "title", "description", "message"} { + if v, ok := input[alias].(string); ok && strings.TrimSpace(v) != "" { + return strings.TrimSpace(v) + } + } + + return "" +} diff --git a/internal/adapter/crush/agents_test.go b/internal/adapter/crush/agents_test.go new file mode 100644 index 0000000..3a941f7 --- /dev/null +++ b/internal/adapter/crush/agents_test.go @@ -0,0 +1,374 @@ +package crush + +import ( + "path/filepath" + "testing" + "time" + + "github.com/cosmtrek/mindwalk/internal/model" +) + +// TestBuildAgentGraphExactMatch verifies the happy path: a launched +// `agent` tool call whose result contains an agent_id matches the +// child row in the catalog, and the resulting graph node is tagged +// `LinkQuality=exact` with `TraceAvailability=available`. +func TestBuildAgentGraphExactMatch(t *testing.T) { + data, db := newFixtureDB(t, nil) + base := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC) + + // Root session that spawns an agent via the `agent` tool. + insertSession(t, db, "root", "", "Root", base, 1) + + launchID := "agent_call_1" + childMsgID := "msg_child_1" + insertMessage(t, db, "root", "msg_root_1", "assistant", writeParts(t, + map[string]any{"type": "tool_call", "data": map[string]any{ + "id": launchID, "name": "agent", + "input": `{"task_title":"explore","agent_type":"explore","message":"read the schema"}`, + "finished": true, + }}, + map[string]any{"type": "tool_result", "data": map[string]any{ + "tool_call_id": launchID, + "name": "agent", + "content": `{"agent_id":"` + childMsgID + `","nickname":"explore","task_name":"explore"}`, + }}, + ), "", base) + + // Child session whose id encodes the parent message id and the + // launching tool call id, matching the format Crush uses. + childID := childMsgID + "$$" + launchID + insertSession(t, db, childID, "root", "Agent: explore", base.Add(time.Second), 2) + + root := model.SessionMeta{ + Key: SessionKey("root"), + ID: "root", + Harness: "crush", + Path: SessionPath("root"), + EventCount: 1, + } + + childMeta, err := Adapter{Dir: data}.Summarize(SessionPath(childID)) + if err != nil { + t.Fatalf("summarize child: %v", err) + } + + graph, err := Adapter{Dir: data}.BuildAgentGraph(root, []model.SessionMeta{childMeta}) + if err != nil { + t.Fatalf("BuildAgentGraph: %v", err) + } + + if graph.RootSessionKey != root.Key { + t.Fatalf("root key = %q", graph.RootSessionKey) + } + + if len(graph.Agents) != 2 { + t.Fatalf("agents = %d, want 2 (main + exact child)", len(graph.Agents)) + } + + var sub *model.AgentNode + + for i := range graph.Agents { + if graph.Agents[i].Kind == model.AgentKindSubagent { + sub = &graph.Agents[i] + + break + } + } + + if sub == nil { + t.Fatalf("no subagent node in graph: %+v", graph.Agents) + } + + if sub.LinkQuality != model.AgentLinkQualityExact { + t.Fatalf("link quality = %q, want exact", sub.LinkQuality) + } + + if sub.TraceAvailability != model.TraceAvailabilityAvailable { + t.Fatalf("trace availability = %q, want available", sub.TraceAvailability) + } + + if sub.TraceSessionKey != childMeta.Key { + t.Fatalf("trace session key = %q, want %q", sub.TraceSessionKey, childMeta.Key) + } + + if sub.LaunchCallID != launchID { + t.Fatalf("launch call id = %q", sub.LaunchCallID) + } + + if sub.InstructionPreview == "" { + t.Fatalf("instruction preview should be populated from message arg") + } +} + +// TestBuildAgentGraphUnlinkedLaunch covers the branch where the +// agent tool returned a non-JSON or empty result: the launch is +// surfaced as an `unavailable` node with the launching tool call id +// as the label. +func TestBuildAgentGraphUnlinkedLaunch(t *testing.T) { + data, db := newFixtureDB(t, nil) + base := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC) + + insertSession(t, db, "root", "", "Root", base, 1) + + launchID := "agent_call_no_match" + insertMessage(t, db, "root", "msg_root_1", "assistant", writeParts(t, + map[string]any{"type": "tool_call", "data": map[string]any{ + "id": launchID, "name": "agent", + "input": `{"task_title":"explore","agent_type":"explore","message":"read"}`, + "finished": true, + }}, + // Garbage result — not the expected JSON shape with + // agent_id, so the launch goes into the unlinked branch. + map[string]any{"type": "tool_result", "data": map[string]any{ + "tool_call_id": launchID, + "name": "agent", + "content": "this is not the expected json shape", + }}, + ), "", base) + + root := model.SessionMeta{Key: SessionKey("root"), ID: "root", Harness: "crush", Path: SessionPath("root")} + + graph, err := Adapter{Dir: data}.BuildAgentGraph(root, nil) + if err != nil { + t.Fatal(err) + } + + if len(graph.Agents) != 2 { + t.Fatalf("agents = %d, want 2", len(graph.Agents)) + } + + var sub *model.AgentNode + + for i := range graph.Agents { + if graph.Agents[i].Kind == model.AgentKindSubagent { + sub = &graph.Agents[i] + } + } + + if sub == nil { + t.Fatalf("no subagent node") + } + + if sub.LinkQuality != model.AgentLinkQualityUnavailable { + t.Fatalf("link quality = %q, want unavailable", sub.LinkQuality) + } + + if sub.TraceAvailability != model.TraceAvailabilityMissing { + t.Fatalf("trace availability = %q, want missing", sub.TraceAvailability) + } + + if sub.Status != model.AgentStatusFailed { + t.Fatalf("status = %q, want failed (garbage output)", sub.Status) + } +} + +// TestBuildAgentGraphDerivedChild covers the branch where a child +// session exists in the catalog but no matching agent launch was +// observed in the root session's messages. The child still surfaces +// in the graph with LinkQuality=derived so the user can investigate. +func TestBuildAgentGraphDerivedChild(t *testing.T) { + data, db := newFixtureDB(t, nil) + base := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC) + + insertSession(t, db, "root", "", "Root", base, 1) + // Root has only a non-agent tool call, so no launches are read. + insertMessage(t, db, "root", "msg_root_1", "assistant", writeParts(t, + map[string]any{"type": "tool_call", "data": map[string]any{ + "id": "view_1", "name": "view", + "input": `{"file_path":"foo.go"}`, "finished": true, + }}, + ), "", base) + + // A child whose parent_session_id is "root" but whose id uses + // the agent-tool format with a launch id that was never recorded. + childID := "msg_some_old$$orphan_call" + insertSession(t, db, childID, "root", "Agent: orphan", base.Add(time.Second), 1) + + childMeta, err := Adapter{Dir: data}.Summarize(SessionPath(childID)) + if err != nil { + t.Fatal(err) + } + + root := model.SessionMeta{Key: SessionKey("root"), ID: "root", Harness: "crush", Path: SessionPath("root")} + + graph, err := Adapter{Dir: data}.BuildAgentGraph(root, []model.SessionMeta{childMeta}) + if err != nil { + t.Fatal(err) + } + + var sub *model.AgentNode + + for i := range graph.Agents { + if graph.Agents[i].Kind == model.AgentKindSubagent { + sub = &graph.Agents[i] + } + } + + if sub == nil { + t.Fatalf("expected derived subagent node, got %+v", graph.Agents) + } + + if sub.LinkQuality != model.AgentLinkQualityDerived { + t.Fatalf("link quality = %q, want derived", sub.LinkQuality) + } + + if sub.TraceAvailability != model.TraceAvailabilityAvailable { + t.Fatalf("trace availability = %q, want available", sub.TraceAvailability) + } + + if sub.LaunchCallID != "orphan_call" { + t.Fatalf("launch call id = %q, want orphan_call", sub.LaunchCallID) + } +} + +// TestBuildAgentGraphEmptyCatalog verifies the graph contains only +// the main node when no children are present in the catalog. +func TestBuildAgentGraphEmptyCatalog(t *testing.T) { + data, db := newFixtureDB(t, nil) + base := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC) + insertSession(t, db, "root", "", "Root", base, 1) + insertMessage(t, db, "root", "msg_root_1", "assistant", writeParts(t, + map[string]any{"type": "text", "data": map[string]any{"text": "Just thinking."}}, + ), "", base) + + root := model.SessionMeta{Key: SessionKey("root"), ID: "root", Harness: "crush", Path: SessionPath("root")} + + graph, err := Adapter{Dir: data}.BuildAgentGraph(root, nil) + if err != nil { + t.Fatal(err) + } + + if len(graph.Agents) != 1 { + t.Fatalf("agents = %d, want 1 (main only)", len(graph.Agents)) + } + + if graph.Agents[0].Kind != model.AgentKindMain { + t.Fatalf("kind = %q", graph.Agents[0].Kind) + } +} + +// TestAgentGraphInputsReturnsRootPath verifies the inputs helper +// surfaces the root session path. Tests that want a deterministic +// catalog without touching the host filesystem use a t.TempDir. +func TestAgentGraphInputsReturnsRootPath(t *testing.T) { + root := model.SessionMeta{Key: "k", ID: "i", Harness: "crush", Path: SessionPath("xyz")} + + got, err := Adapter{Dir: filepath.Join(t.TempDir(), "no-crush")}.AgentGraphInputs(root, nil) + if err != nil { + t.Fatal(err) + } + + if len(got) != 1 || got[0] != root.Path { + t.Fatalf("inputs = %v, want [%q]", got, root.Path) + } + // Empty root path: no path to discover, but the inputs helper + // must not crash. It returns whatever auxiliary paths the + // catalog/database had — here, none. + empty, _ := Adapter{Dir: filepath.Join(t.TempDir(), "no-crush")}.AgentGraphInputs(model.SessionMeta{}, nil) + for _, p := range empty { + if p != "" { + t.Fatalf("empty root path should produce no non-empty inputs, got %v", empty) + } + } +} + +// TestIndexChildrenByParentGroupsCrushAuxiliaries verifies the index +// helper groups auxiliary sessions by their parent_session_id and +// ignores sessions from other harnesses. +func TestIndexChildrenByParentGroupsCrushAuxiliaries(t *testing.T) { + catalog := []model.SessionMeta{ + {Key: "a", Harness: "crush", Agent: &model.AgentSessionMeta{RootSessionID: "p1", LaunchCallID: "c1"}}, + {Key: "b", Harness: "crush", Agent: &model.AgentSessionMeta{RootSessionID: "p1", LaunchCallID: "c2"}}, + {Key: "c", Harness: "crush", Agent: &model.AgentSessionMeta{RootSessionID: "p2", LaunchCallID: "c3"}}, + {Key: "d", Harness: "claudecode", Agent: &model.AgentSessionMeta{RootSessionID: "p1", LaunchCallID: "ignored"}}, + {Key: "e", Harness: "crush"}, // no Agent metadata -> ignored + } + + idx := indexChildrenByParent(catalog) + if len(idx["p1"]) != 2 { + t.Fatalf("p1 children = %d, want 2", len(idx["p1"])) + } + + if len(idx["p2"]) != 1 { + t.Fatalf("p2 children = %d, want 1", len(idx["p2"])) + } + + if idx["p1"][0].Key != "a" || idx["p1"][1].Key != "b" { + t.Fatalf("p1 not sorted: %v", idx["p1"]) + } +} + +// TestParseCrushLaunchOutputCoversBranches verifies the small helper +// that extracts agent_id / nickname / task_name from the result +// JSON behaves as expected across the supported shapes. +func TestParseCrushLaunchOutputCoversBranches(t *testing.T) { + tests := []struct { + name string + raw string + wantOK bool + wantAgent string + }{ + {"all_fields", `{"agent_id":"a1","nickname":"nick","task_name":"t"}`, true, "a1"}, + {"agent_id_only", `{"agent_id":"a1"}`, true, "a1"}, + {"empty_object", `{}`, false, ""}, + {"empty_string", "", false, ""}, + {"non_json", "not json", false, ""}, + {"nickname_only", `{"nickname":"n"}`, true, ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, ok := parseCrushLaunchOutput(tt.raw) + if ok != tt.wantOK { + t.Fatalf("ok = %v, want %v", ok, tt.wantOK) + } + + if ok && got.AgentID != tt.wantAgent { + t.Fatalf("agent_id = %q, want %q", got.AgentID, tt.wantAgent) + } + }) + } +} + +// TestCrushChildDepthPrefersRecorded verifies the depth helper +// uses a child's recorded depth when set, otherwise defaults to +// parent+1. +func TestCrushChildDepthPrefersRecorded(t *testing.T) { + if got := crushChildDepth(3, 1); got != 3 { + t.Fatalf("recorded wins: got %d", got) + } + + if got := crushChildDepth(0, 2); got != 3 { + t.Fatalf("fallback parent+1: got %d", got) + } + + if got := crushChildDepth(-1, 2); got != 3 { + t.Fatalf("negative recorded falls back: got %d", got) + } +} + +// TestAgentArgumentReadsAlias verifies the helper falls back from +// the canonical key through the conventional aliases. +func TestAgentArgumentReadsAlias(t *testing.T) { + cases := []struct { + name string + input map[string]any + key string + want string + }{ + {"primary", map[string]any{"agent_type": "explore"}, "agent_type", "explore"}, + {"task_title", map[string]any{"task_title": "review"}, "agent_type", "review"}, + {"title", map[string]any{"title": "investigate"}, "agent_type", "investigate"}, + {"description", map[string]any{"description": "look closer"}, "agent_type", "look closer"}, + {"message", map[string]any{"message": "deep dive"}, "agent_type", "deep dive"}, + {"missing", map[string]any{}, "agent_type", ""}, + {"non_string", map[string]any{"agent_type": 42}, "agent_type", ""}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got := agentArgument(c.input, c.key) + if got != c.want { + t.Fatalf("got %q, want %q", got, c.want) + } + }) + } +} diff --git a/internal/adapter/crush/diagnostics.go b/internal/adapter/crush/diagnostics.go new file mode 100644 index 0000000..63f7407 --- /dev/null +++ b/internal/adapter/crush/diagnostics.go @@ -0,0 +1,133 @@ +package crush + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/cosmtrek/mindwalk/internal/adapter" +) + +// Diagnostics runs health checks on the Crush adapter's configuration +// and every discovered database. It checks: +// +// - Data directory readability +// - projects.json registry validity (auto-discover mode) +// - Schema column coverage on each database +// +// The method is safe to call on a zero-value Adapter (no dbCache); +// each database is opened and closed independently. +func (a Adapter) Diagnostics() []adapter.DiagnosticCheck { + var checks []adapter.DiagnosticCheck + + dir := a.SessionDir() + if dir == "" { + checks = append(checks, adapter.DiagnosticCheck{ + Name: "data-dir", + Status: "error", + Detail: "no Crush data directory could be resolved", + }) + + return checks + } + + if info, err := os.Stat(dir); err != nil || !info.IsDir() { + checks = append(checks, adapter.DiagnosticCheck{ + Name: "data-dir", + Status: "warn", + Detail: fmt.Sprintf("data directory %s does not exist or is not a directory", dir), + }) + } else { + checks = append(checks, adapter.DiagnosticCheck{ + Name: "data-dir", + Status: "ok", + Detail: dir, + }) + } + + if a.Dir == "" { + registry := filepath.Join(DefaultDir(), "projects.json") + + dbs := loadProjectDBs() + if len(dbs) == 0 { + if _, err := os.Stat(registry); err != nil { + checks = append(checks, adapter.DiagnosticCheck{ + Name: "projects.json", + Status: "warn", + Detail: "registry not found at " + registry, + }) + } else { + checks = append(checks, adapter.DiagnosticCheck{ + Name: "projects.json", + Status: "warn", + Detail: fmt.Sprintf("registry at %s contains no valid project databases", registry), + }) + } + } else { + checks = append(checks, adapter.DiagnosticCheck{ + Name: "projects.json", + Status: "ok", + Detail: fmt.Sprintf("%d project database(s) discovered", len(dbs)), + }) + } + } + + for _, dbPath := range a.enumerateDBPaths() { + label := dbLabel(dbPath) + + h, err := openAt(dbPath) + if err != nil { + checks = append(checks, adapter.DiagnosticCheck{ + Name: "db:" + label, + Status: "error", + Detail: fmt.Sprintf("cannot open: %v", err), + }) + + continue + } + + if h == nil { + checks = append(checks, adapter.DiagnosticCheck{ + Name: "db:" + label, + Status: "warn", + Detail: "database file not found or empty", + }) + + continue + } + + missing := h.missingColumns() + _ = h.close() + + if len(missing) > 0 { + checks = append(checks, adapter.DiagnosticCheck{ + Name: "db:" + label, + Status: "warn", + Detail: "missing columns: " + strings.Join(missing, ", "), + }) + } else { + checks = append(checks, adapter.DiagnosticCheck{ + Name: "db:" + label, + Status: "ok", + Detail: "schema current", + }) + } + } + + return checks +} + +// dbLabel produces a short label for a database path, using the parent +// directory name so multiple project databases are distinguishable in +// the doctor output. +func dbLabel(dbPath string) string { + parent := filepath.Dir(dbPath) + + base := filepath.Base(parent) + if base == "." || base == string(filepath.Separator) { + return filepath.Base(dbPath) + } + + return base +} diff --git a/internal/adapter/crush/diagnostics_test.go b/internal/adapter/crush/diagnostics_test.go new file mode 100644 index 0000000..4369645 --- /dev/null +++ b/internal/adapter/crush/diagnostics_test.go @@ -0,0 +1,151 @@ +package crush + +import ( + "database/sql" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/cosmtrek/mindwalk/internal/adapter" + _ "modernc.org/sqlite" +) + +func findCheck(checks []adapter.DiagnosticCheck, prefix string) (adapter.DiagnosticCheck, bool) { + for _, c := range checks { + if strings.HasPrefix(c.Name, prefix) { + return c, true + } + } + + return adapter.DiagnosticCheck{}, false +} + +func TestDiagnosticsFullSchema(t *testing.T) { + root := t.TempDir() + + data := filepath.Join(root, ".crush") + if err := os.MkdirAll(data, 0o755); err != nil { + t.Fatal(err) + } + + dbPath := filepath.Join(data, "crush.db") + + db, err := sql.Open("sqlite", "file:"+dbPath) + if err != nil { + t.Fatal(err) + } + + db.SetMaxOpenConns(1) + // Full schema with every column schemaMissingColumns expects in messages. + _, err = db.Exec(`CREATE TABLE sessions ( + id TEXT PRIMARY KEY, parent_session_id TEXT, title TEXT NOT NULL, + message_count INTEGER NOT NULL DEFAULT 0, + prompt_tokens INTEGER NOT NULL DEFAULT 0, + completion_tokens INTEGER NOT NULL DEFAULT 0, + cost REAL NOT NULL DEFAULT 0.0, + updated_at INTEGER NOT NULL, created_at INTEGER NOT NULL, todos TEXT + ); + CREATE TABLE messages ( + id TEXT PRIMARY KEY, session_id TEXT NOT NULL, role TEXT NOT NULL, + parts TEXT NOT NULL DEFAULT '[]', + model TEXT, provider TEXT, parent_session_id TEXT, + created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, finished_at INTEGER + );`) + if err != nil { + t.Fatal(err) + } + + _, err = db.Exec(`INSERT INTO sessions (id, title, updated_at, created_at) VALUES ('s1', 'test', 0, 0)`) + if err != nil { + t.Fatal(err) + } + + _ = db.Close() + + a := Adapter{Dir: data} + checks := a.Diagnostics() + + dirCheck, ok := findCheck(checks, "data-dir") + if !ok { + t.Fatal("expected a data-dir check") + } + + if dirCheck.Status != "ok" { + t.Fatalf("data-dir status = %q, want ok", dirCheck.Status) + } + + dbCheck, ok := findCheck(checks, "db:") + if !ok { + t.Fatal("expected a db:* check") + } + + if dbCheck.Status != "ok" { + t.Fatalf("db status = %q, want ok (detail: %s)", dbCheck.Status, dbCheck.Detail) + } + + if !strings.Contains(dbCheck.Detail, "schema current") { + t.Fatalf("db detail = %q, want 'schema current'", dbCheck.Detail) + } +} + +func TestDiagnosticsMissingColumns(t *testing.T) { + root := t.TempDir() + + data := filepath.Join(root, ".crush") + if err := os.MkdirAll(data, 0o755); err != nil { + t.Fatal(err) + } + + dbPath := filepath.Join(data, "crush.db") + + db, err := sql.Open("sqlite", "file:"+dbPath) + if err != nil { + t.Fatal(err) + } + + db.SetMaxOpenConns(1) + + _, err = db.Exec(`CREATE TABLE sessions (id TEXT, title TEXT, updated_at INTEGER, created_at INTEGER); + CREATE TABLE messages (id TEXT, session_id TEXT, role TEXT, parts TEXT, created_at INTEGER, updated_at INTEGER);`) + if err != nil { + t.Fatal(err) + } + + _, err = db.Exec(`INSERT INTO sessions (id, title, updated_at, created_at) VALUES ('s1', 'test', 0, 0)`) + if err != nil { + t.Fatal(err) + } + + _ = db.Close() + + a := Adapter{Dir: data} + checks := a.Diagnostics() + + dbCheck, ok := findCheck(checks, "db:") + if !ok { + t.Fatal("expected a db:* check") + } + + if dbCheck.Status != "warn" { + t.Fatalf("db status = %q, want warn", dbCheck.Status) + } + + if !strings.Contains(dbCheck.Detail, "missing columns") { + t.Fatalf("db detail = %q, want 'missing columns'", dbCheck.Detail) + } +} + +func TestDiagnosticsDataDirMissing(t *testing.T) { + a := Adapter{Dir: filepath.Join(t.TempDir(), "nonexistent")} + checks := a.Diagnostics() + + dirCheck, ok := findCheck(checks, "data-dir") + if !ok { + t.Fatal("expected a data-dir check") + } + + if dirCheck.Status != "warn" { + t.Fatalf("data-dir status = %q, want warn", dirCheck.Status) + } +} diff --git a/internal/adapter/crush/fixture_test.go b/internal/adapter/crush/fixture_test.go new file mode 100644 index 0000000..08bb04b --- /dev/null +++ b/internal/adapter/crush/fixture_test.go @@ -0,0 +1,376 @@ +package crush + +import ( + "path/filepath" + "testing" + + "github.com/cosmtrek/mindwalk/internal/model" +) + +// fixtureDir is the path to the committed testdata/crush/crush.db +// fixture. The fixture is built once by the gencrushfixture helper +// (not committed) and checked into git so CI reproduces the +// end-to-end Crush verification path without a host install. +func fixtureDir(t *testing.T) string { + t.Helper() + + return filepath.Join("..", "..", "..", "testdata", "crush") +} + +// TestFixtureLoadsAllSessions walks the committed fixture and +// verifies both the root session and the auxiliary agent session +// surface through ListSessions with the expected metadata. +func TestFixtureLoadsAllSessions(t *testing.T) { + dir := fixtureDir(t) + + metas, err := Adapter{Dir: dir}.ListSessions() + if err != nil { + t.Fatalf("ListSessions: %v", err) + } + + if len(metas) != 1 { + t.Fatalf("metas = %d, want 1 (child sessions should be hidden)", len(metas)) + } + + root := metas[0] + if root.ID != "fixture-root" { + t.Fatalf("id = %q", root.ID) + } + + if root.Harness != "crush" { + t.Fatalf("harness = %q", root.Harness) + } + + if root.Title != "Fixture root session" { + t.Fatalf("title = %q", root.Title) + } + + if !IsSessionPath(root.Path) { + t.Fatalf("path = %q, want crush://session/", root.Path) + } +} + +// TestFixtureParsesTraceAsExpected decodes the root session into a +// Trace and checks the event count, model field, and the marks +// (user-message + subagent). This is the same path the server's +// /api/sessions//trace endpoint takes. +func TestFixtureParsesTraceAsExpected(t *testing.T) { + dir := fixtureDir(t) + + trace, err := Adapter{Dir: dir}.Parse(SessionPath("fixture-root")) + if err != nil { + t.Fatalf("Parse: %v", err) + } + + if trace.Session.Harness != "crush" { + t.Fatalf("harness = %q", trace.Session.Harness) + } + + if trace.Session.Model != "minimax/minimax-m3" { + t.Fatalf("model = %q", trace.Session.Model) + } + // Four events: agent (subagent), read, write, bash. + if len(trace.Events) != 4 { + t.Fatalf("events = %d, want 4 (agent + read + write + bash)", len(trace.Events)) + } + + toolNames := make(map[string]bool) + for _, ev := range trace.Events { + toolNames[ev.Tool] = true + } + + for _, want := range []string{"agent", "read", "write", "bash"} { + if !toolNames[want] { + t.Fatalf("missing %q event in trace; tools present: %v", want, toolNames) + } + } + // Three marks: user-message (first turn), subagent (agent call), + // and user-message (second turn with finish/stop). + if len(trace.Marks) != 3 { + t.Fatalf("marks = %+v", trace.Marks) + } + + sawUser, sawSub := 0, false + + for _, m := range trace.Marks { + switch m.Type { + case "user-message": + sawUser++ + case "subagent": + sawSub = true + + if m.Note != "read server" { + t.Fatalf("subagent note = %q", m.Note) + } + } + } + + if sawUser != 2 { + t.Fatalf("expected 2 user-message marks, got %d", sawUser) + } + + if !sawSub { + t.Fatalf("expected a subagent mark") + } +} + +// TestFixtureFileTouchingEventsHaveTargets verifies the enriched +// fixture's read, write, and bash tool calls each produce trace events +// with tool names set correctly — exercising the path the server's +// trace endpoint and citymap builder consume. +func TestFixtureFileTouchingEventsHaveTargets(t *testing.T) { + dir := fixtureDir(t) + + trace, err := Adapter{Dir: dir}.Parse(SessionPath("fixture-root")) + if err != nil { + t.Fatalf("Parse: %v", err) + } + + byTool := map[string]model.Event{} + for _, ev := range trace.Events { + byTool[ev.Tool] = ev + } + + for _, want := range []string{"read", "write", "bash"} { + ev, ok := byTool[want] + if !ok { + t.Fatalf("missing %q event", want) + } + + if ev.Seq < 0 { + t.Fatalf("%q event has negative seq: %d", want, ev.Seq) + } + } + // The read event should reference a file path via an outside + // touch (the fixture has no Cwd, so absolute paths land outside). + readEv := byTool["read"] + if len(readEv.Outside) == 0 && len(readEv.Targets) == 0 { + t.Fatalf("read event has no targets or outside touches: %+v", readEv) + } + // The write event should also touch a file. + writeEv := byTool["write"] + if len(writeEv.Outside) == 0 && len(writeEv.Targets) == 0 { + t.Fatalf("write event has no targets or outside touches: %+v", writeEv) + } +} + +// TestFixtureSummarizesChildSession ensures the auxiliary session +// is reachable via Summarize with the correct SourceID and +// LaunchCallID parsed from the `$$` separator. +func TestFixtureSummarizesChildSession(t *testing.T) { + dir := fixtureDir(t) + + meta, err := Adapter{Dir: dir}.Summarize(SessionPath("m_assistant_1$$call_agent_1")) + if err != nil { + t.Fatalf("Summarize: %v", err) + } + + if !meta.Auxiliary { + t.Fatalf("Auxiliary = false, want true") + } + + if meta.Agent == nil { + t.Fatalf("Agent meta nil") + } + + if meta.Agent.SourceID != "m_assistant_1" { + t.Fatalf("SourceID = %q", meta.Agent.SourceID) + } + + if meta.Agent.LaunchCallID != "call_agent_1" { + t.Fatalf("LaunchCallID = %q", meta.Agent.LaunchCallID) + } + + if meta.Agent.RootSessionID != "fixture-root" { + t.Fatalf("RootSessionID = %q", meta.Agent.RootSessionID) + } +} + +// TestFixtureBuildsAgentGraph verifies the agent graph builder +// emits the main node plus the auxiliary child node when given the +// fixture's catalog. +func TestFixtureBuildsAgentGraph(t *testing.T) { + dir := fixtureDir(t) + adapter := Adapter{Dir: dir} + + metas, err := adapter.ListSessions() + if err != nil { + t.Fatal(err) + } + + childMeta, err := adapter.Summarize(SessionPath("m_assistant_1$$call_agent_1")) + if err != nil { + t.Fatal(err) + } + + root := metas[0] + + graph, err := adapter.BuildAgentGraph(root, []model.SessionMeta{childMeta}) + if err != nil { + t.Fatalf("BuildAgentGraph: %v", err) + } + + if graph.RootSessionKey != root.Key { + t.Fatalf("root key = %q", graph.RootSessionKey) + } + + if len(graph.Agents) != 2 { + t.Fatalf("agents = %d, want 2 (main + child)", len(graph.Agents)) + } + + var sub *model.AgentNode + + for i := range graph.Agents { + if graph.Agents[i].Kind == model.AgentKindSubagent { + sub = &graph.Agents[i] + } + } + + if sub == nil { + t.Fatalf("no subagent node in graph: %+v", graph.Agents) + } + + if sub.LinkQuality != model.AgentLinkQualityExact { + t.Fatalf("link quality = %q, want exact", sub.LinkQuality) + } + + if sub.TraceSessionKey != childMeta.Key { + t.Fatalf("trace key = %q, want %q", sub.TraceSessionKey, childMeta.Key) + } +} + +// TestFixtureTokenEconomics verifies the fixture carries non-zero +// token and cost values so the exact-observability code path is +// exercised end-to-end. +func TestFixtureTokenEconomics(t *testing.T) { + dir := fixtureDir(t) + + metas, err := Adapter{Dir: dir}.ListSessions() + if err != nil { + t.Fatalf("ListSessions: %v", err) + } + + if len(metas) != 1 { + t.Fatalf("metas = %d, want 1", len(metas)) + } + + root := metas[0] + if root.PromptTokens == 0 { + t.Error("PromptTokens = 0, want non-zero") + } + + if root.CompletionTokens == 0 { + t.Error("CompletionTokens = 0, want non-zero") + } + + if root.Cost == 0 { + t.Error("Cost = 0, want non-zero") + } +} + +// TestFixtureReadObservability verifies the read_files table in the +// fixture upgrades the read observability grade from estimated to exact. +func TestFixtureReadObservability(t *testing.T) { + dir := fixtureDir(t) + + trace, err := Adapter{Dir: dir}.Parse(SessionPath("fixture-root")) + if err != nil { + t.Fatalf("Parse: %v", err) + } + + if trace.Stats.Observability.Reads != model.ObservabilityExact { + t.Fatalf("reads observability = %q, want exact (read_files table should upgrade it)", + trace.Stats.Observability.Reads) + } +} + +// TestFixtureErrorObservability pins the regression for T06: the +// crush adapter must mark every observed tool_result as +// OutcomeKnown so the error observability grade becomes exact when +// no event was left unmatched. ComputeStats will auto-degrade back +// to estimated the moment any event carries OutcomeKnown=false, +// which acts as a tripwire for future schema drift. +func TestFixtureErrorObservability(t *testing.T) { + t.Parallel() + + dir := fixtureDir(t) + + trace, err := Adapter{Dir: dir}.Parse(SessionPath("fixture-root")) + if err != nil { + t.Fatalf("Parse: %v", err) + } + + for i, ev := range trace.Events { + if !ev.IsError && !ev.OutcomeKnown { + t.Errorf("event[%d] (%s) has unknown outcome; parts.go should mark observed results as known", + i, ev.Tool) + } + } + + if trace.Stats.Observability.Errors != model.ObservabilityExact { + t.Fatalf("errors observability = %q, want exact (every observed result is OutcomeKnown)", + trace.Stats.Observability.Errors) + } +} + +// BenchmarkFixtureListSessions measures the cold-open + session-listing +// path. Each iteration opens the SQLite file read-only, runs the +// sessions query, and closes the handle. +func BenchmarkFixtureListSessions(b *testing.B) { + dir := filepath.Join("..", "..", "..", "testdata", "crush") + a := Adapter{Dir: dir} + + b.ReportAllocs() + + for b.Loop() { + if _, err := a.ListSessions(); err != nil { + b.Fatal(err) + } + } +} + +// BenchmarkFixtureParse measures the full trace-parse path: open DB, +// walk every message row, decode parts JSON, build events/marks, +// close. This is the path /api/sessions//trace exercises. +func BenchmarkFixtureParse(b *testing.B) { + dir := filepath.Join("..", "..", "..", "testdata", "crush") + a := Adapter{Dir: dir} + path := SessionPath("fixture-root") + + b.ReportAllocs() + + for b.Loop() { + if _, err := a.Parse(path); err != nil { + b.Fatal(err) + } + } +} + +// BenchmarkFixtureBuildAgentGraph measures the cold + warm graph +// build path: ListSessions (for the catalog), then BuildAgentGraph. +// This is the path /api/sessions//agent-graph exercises. v0.3.0 +// replaced the recursive CTE with a single WITH RECURSIVE — keep +// this benchmark as the regression baseline for that change. +func BenchmarkFixtureBuildAgentGraph(b *testing.B) { + dir := filepath.Join("..", "..", "..", "testdata", "crush") + a := Adapter{Dir: dir} + + catalog, err := a.ListSessions() + if err != nil { + b.Fatal(err) + } + + if len(catalog) == 0 { + b.Skip("fixture catalog is empty — nothing to graph") + } + + root := catalog[0] + + b.ReportAllocs() + + for b.Loop() { + if _, err := a.BuildAgentGraph(root, catalog); err != nil { + b.Fatal(err) + } + } +} diff --git a/internal/adapter/crush/fuzz_test.go b/internal/adapter/crush/fuzz_test.go new file mode 100644 index 0000000..332b8d2 --- /dev/null +++ b/internal/adapter/crush/fuzz_test.go @@ -0,0 +1,30 @@ +package crush + +import "testing" + +// FuzzDecodeParts verifies that arbitrary JSON input never panics the +// parts decoder. The decoder must handle malformed, truncated, and +// adversarial JSON gracefully. +func FuzzDecodeParts(f *testing.F) { + f.Add(`[{"type":"text","data":{"text":"hello"}}]`) + f.Add(`[{"type":"tool_call","data":{"id":"c1","name":"read","input":"{}"}}]`) + f.Add(`[]`) + f.Add(`malformed`) + f.Add(`{"not":"array"}`) + f.Fuzz(func(t *testing.T, raw string) { + _, _ = decodeParts(raw, "") + // The invariant: decodeParts must not panic on any input. + }) +} + +// FuzzSplitAgentID verifies the agent-id parser never panics. +func FuzzSplitAgentID(f *testing.F) { + f.Add("m_assistant_1$$call_agent_1") + f.Add("simple") + f.Add("$$") + f.Add("") + f.Add("a$$b$$c") + f.Fuzz(func(t *testing.T, id string) { + splitAgentID(id) + }) +} diff --git a/internal/adapter/crush/handle.go b/internal/adapter/crush/handle.go new file mode 100644 index 0000000..d488429 --- /dev/null +++ b/internal/adapter/crush/handle.go @@ -0,0 +1,78 @@ +package crush + +import ( + "errors" + "fmt" + "os" + "path/filepath" + + crushdata "github.com/LarsArtmann/go-crush-data" +) + +// dbHandle wraps a read-only crushdata.DB with the database path it +// was opened from so callers can attribute results to a project. +// When cached is true the handle came from the adapter's dbCache and +// close() is a no-op so the connection survives across requests. +type dbHandle struct { + path string + inner *crushdata.DB + cached bool +} + +// openAt opens the crush.db that lives next to dbPath via the +// go-crush-data SDK. A missing database yields (nil, nil) so callers +// can treat "no Crush data" as an empty catalog instead of an error; +// a database that exists but is broken (empty file, directory in the +// way, missing required tables) surfaces a distinct error naming the +// path so a user can fix the configuration without re-deriving what +// went wrong. +func openAt(dbPath string) (*dbHandle, error) { + info, err := os.Stat(dbPath) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + + return nil, fmt.Errorf("inspect crush database at %s: %w", dbPath, err) + } + + if info.IsDir() { + return nil, fmt.Errorf("crush database path %s is a directory, not a file", dbPath) + } + + if info.Size() == 0 { + return nil, fmt.Errorf("crush database at %s is empty (size 0)", dbPath) + } + + db, err := crushdata.Open(filepath.Dir(dbPath)) + if err != nil { + if errors.Is(err, crushdata.ErrDatabaseNotFound) { + return nil, nil + } + + return nil, err + } + + return &dbHandle{path: dbPath, inner: db}, nil +} + +// close releases the handle unless it came from the adapter cache. +func (h *dbHandle) close() error { + if h == nil || h.inner == nil || h.cached { + return nil + } + + return h.inner.Close() +} + +// closeDiscard calls close() and drops its error for defer sites. +func (h *dbHandle) closeDiscard() { + _ = h.close() +} + +// missingColumns reports the well-known columns the database lacks, +// via the SDK's capability probe (sessions.cost, parent_session_id, +// messages.model, provider, finished_at, read_files). +func (h *dbHandle) missingColumns() []string { + return h.inner.Schema().MissingColumns() +} diff --git a/internal/adapter/crush/parts.go b/internal/adapter/crush/parts.go new file mode 100644 index 0000000..c8b131b --- /dev/null +++ b/internal/adapter/crush/parts.go @@ -0,0 +1,284 @@ +package crush + +import ( + "fmt" + "strings" + + crushdata "github.com/LarsArtmann/go-crush-data" + "github.com/cosmtrek/mindwalk/internal/adapter" +) + +// The raw on-disk shape of messages.parts (a discriminated +// {"type","data"} union), its decoding into typed values, and the +// schema-drift tolerance for unknown discriminators all live in the +// go-crush-data SDK. This file folds decoded parts into the +// mindwalk-specific trace view: tool calls paired with results, +// subagent launches, reasoning durations, finish reasons, and +// bang-mode shell commands. + +// partsParser folds one message's decoded parts in declaration order. +// Tool calls and their results are paired by tool_call_id; any orphan +// result is kept for cross-message pairing by the agent graph reader, +// and any tool call without a matching result is emitted as an event +// with an empty ToolResult. +// +// State is held in the parser so callers can stream messages through +// one instance and keep the in-flight tool calls memory-cheap. +type partsParser struct { + pending map[string]adapter.ToolCall + pendingOrder []string + results map[string]adapter.ToolResult + resultOrder []string // tool_call_id for each tool_result part in declaration order + text strings.Builder + subagentNote string + subagentSeen bool + hasUserFinish bool + reasoningText string + reasoningSecs int64 + finishReason string + finishMessage string + shellCommands []crushdata.ShellCommandPart + bashCommands map[string]bool +} + +// newPartsParser constructs an empty parser ready for one message. +func newPartsParser() *partsParser { + return &partsParser{ + pending: map[string]adapter.ToolCall{}, + results: map[string]adapter.ToolResult{}, + bashCommands: map[string]bool{}, + } +} + +// add folds one decoded part into the parser state. Parts the SDK +// could not classify arrive as UnknownPart and are ignored so a Crush +// schema bump never crashes an older mindwalk binary. +func (p *partsParser) add(part crushdata.Part, timestamp string) { + switch typed := part.(type) { + case crushdata.TextPart: + if typed.Text != "" { + if p.text.Len() > 0 { + p.text.WriteString("\n") + } + + p.text.WriteString(typed.Text) + } + case crushdata.ReasoningPart: + if typed.Thinking != "" { + p.reasoningText = typed.Thinking + } + + if typed.FinishedAt > typed.StartedAt { + p.reasoningSecs = typed.FinishedAt - typed.StartedAt + } + case crushdata.ShellCommandPart: + p.shellCommands = append(p.shellCommands, typed) + case crushdata.ToolCallPart: + if typed.ID == "" || typed.Name == "" { + return + } + + input := parseCrushInput(typed.Input) + + call := adapter.ToolCall{ + ID: typed.ID, + Name: typed.Name, + Input: input, + Timestamp: timestamp, + ProviderExecuted: typed.ProviderExecuted, + } + if typed.Name == "agent" { + p.subagentSeen = true + if label := agentLabelFromInput(input); label != "" { + p.subagentNote = label + } else { + p.subagentNote = typed.Name + } + } + + if _, exists := p.pending[typed.ID]; !exists { + p.pendingOrder = append(p.pendingOrder, typed.ID) + } + + p.pending[typed.ID] = call + if typed.Name == "bash" || typed.Name == "Bash" { + if cmd, ok := input["command"].(string); ok && cmd != "" { + p.bashCommands[cmd] = true + } + } + case crushdata.ToolResultPart: + if typed.ToolCallID == "" { + return + } + + // IsError is decoded from the wire `is_error` field, so its + // presence (true or false) means the tool result was observed + // and the outcome is known. + p.results[typed.ToolCallID] = adapter.ToolResult{ + ToolCallID: typed.ToolCallID, + Content: typed.Content, + IsError: typed.IsError, + OutcomeKnown: true, + } + p.resultOrder = append(p.resultOrder, typed.ToolCallID) + case crushdata.FinishPart: + if typed.Reason == "stop" { + p.hasUserFinish = true + } + + p.finishReason = typed.Reason + p.finishMessage = typed.Message + case crushdata.UnknownPart: + // Image, binary, and future part kinds pass through as + // UnknownPart — nothing here consumes their payloads. + } +} + +// agentLabelFromInput pulls the best label from an agent tool call's +// input — task title, then description, then the first non-empty +// string field. +func agentLabelFromInput(input map[string]any) string { + for _, key := range []string{"task_title", "title", "description", "prompt", "message"} { + if v, ok := input[key].(string); ok { + if trimmed := strings.TrimSpace(v); trimmed != "" { + return trimmed + } + } + } + + return "" +} + +// parseCrushInput decodes a tool call input string (which Crush stores +// as raw JSON text in the SQLite column) into the map[string]any shape +// the existing actionFor/targetsFor helpers expect. The adapter never +// blocks on a malformed payload: it falls back to a single "_raw" +// entry so downstream heuristics can still mine it. +func parseCrushInput(raw string) map[string]any { + return adapter.ParseJSONInput(raw) +} + +// finishResult is the materialised trace pieces of one message: +// +// - text: the concatenated message body (used to detect user +// turns and emit a user-message mark). +// - events: ordered tool-call events, each paired with its +// result or an empty ToolResult when no result was +// observed. Order matches declaration in the parts +// JSON. +// - subagent: true when the message observed an `agent` tool +// call — the caller records a subagent mark. +// - userFinish: true when the message ended with a finish reason +// of `stop`, matching Crush's user-typed prompt shape +// (the assistant finishes the user turn with `stop`). +type finishResult struct { + text string + events []adapter.ToolCall + results []adapter.ToolResult + subagent bool + subagentNote string + userFinish bool + reasoningText string + reasoningSecs int64 + finishReason string + finishMessage string +} + +func (p *partsParser) finish() finishResult { + result := finishResult{ + text: strings.TrimSpace(p.text.String()), + subagent: p.subagentSeen, + subagentNote: p.subagentNote, + userFinish: p.hasUserFinish, + reasoningText: p.reasoningText, + reasoningSecs: p.reasoningSecs, + finishReason: p.finishReason, + finishMessage: p.finishMessage, + } + // Surface every tool_call that landed in this message, paired + // with its result when one was observed in the same message. + // An unmatched tool_call gets an empty result so the timeline + // does not silently lose the attempt. Remember which results + // have been paired so the cross-message results loop below + // doesn't double-emit them. + paired := make(map[string]bool, len(p.results)) + for _, id := range p.pendingOrder { + call := p.pending[id] + + result.events = append(result.events, call) + if r, ok := p.results[id]; ok { + result.results = append(result.results, r) + paired[id] = true + } else { + result.results = append(result.results, adapter.ToolResult{}) + } + } + // Append tool results whose originating tool_call lives in a + // different message — they have no event in this message but + // the agent graph reader needs them to pair launches across + // messages. Each result carries its own ToolCallID so the + // consumer can match without a parallel slice. + for _, callID := range p.resultOrder { + if paired[callID] { + continue + } + + result.results = append(result.results, p.results[callID]) + } + // Emit bang-mode shell commands as exec events, but skip any + // whose command string was already captured by a bash tool call + // in the same message to avoid duplicates. + for i, sc := range p.shellCommands { + if sc.Command != "" && p.bashCommands[sc.Command] { + continue + } + + call := adapter.ToolCall{ + ID: shellEventID(i), + Name: "bash", + Input: map[string]any{"command": sc.Command}, + Timestamp: "", + } + result.events = append(result.events, call) + result.results = append(result.results, adapter.ToolResult{ + ToolCallID: call.ID, + Content: sc.Output, + IsError: sc.ExitCode != 0, + OutcomeKnown: true, + }) + } + + return result +} + +// shellEventID builds the synthetic event id for the i-th bang-mode +// shell command. +func shellEventID(i int) string { + return fmt.Sprintf("shell-%d", i) +} + +// foldParts folds one message's decoded parts into its trace pieces. +// Nil parts (empty payload, or JSON the SDK refused) yield the zero +// result — a single corrupted message never poisons the trace. +func foldParts(parts []crushdata.Part, timestamp string) finishResult { + parser := newPartsParser() + + for _, part := range parts { + parser.add(part, timestamp) + } + + return parser.finish() +} + +// decodeParts parses a single messages.parts JSON string and folds it +// into the trace view. Production reads flow through the SDK's +// already-decoded [crushdata.Message.Parts]; this composition serves +// callers that hold the raw column text. +func decodeParts(raw string, timestamp string) (finishResult, error) { + parts, err := crushdata.DecodeParts(raw) + if err != nil { + return finishResult{}, err + } + + return foldParts(parts, timestamp), nil +} diff --git a/internal/adapter/crush/parts_test.go b/internal/adapter/crush/parts_test.go new file mode 100644 index 0000000..c93529d --- /dev/null +++ b/internal/adapter/crush/parts_test.go @@ -0,0 +1,419 @@ +package crush + +import ( + "encoding/json" + "testing" +) + +// TestDecodePartsReasoning ensures reasoning parts decode without +// surfacing in the message text but still validate their payload. +// Reasoning is captured by Crush for every assistant turn; the +// visualizer does not have a thinking lane today, but the schema +// coverage keeps a future bump loud. +func TestDecodePartsReasoning(t *testing.T) { + raw := writeParts( + t, + map[string]any{ + "type": "reasoning", + "data": map[string]any{"thinking": "the user asked for X, I should do Y first"}, + }, + map[string]any{"type": "text", "data": map[string]any{"text": "Doing the thing."}}, + ) + + result, err := decodeParts(raw, "") + if err != nil { + t.Fatal(err) + } + + if result.text != "Doing the thing." { + t.Fatalf("text = %q", result.text) + } + + if len(result.events) != 0 { + t.Fatalf("reasoning should not emit events, got %+v", result.events) + } +} + +// TestDecodePartsFinishStopSetsUserFinish verifies the finish/stop +// branch records userFinish=true so the Parse loop emits a +// user-message mark for the trace. +func TestDecodePartsFinishStopSetsUserFinish(t *testing.T) { + raw := writeParts(t, + map[string]any{"type": "text", "data": map[string]any{"text": "ship the fix"}}, + map[string]any{"type": "finish", "data": map[string]any{"reason": "stop", "time": 0}}, + ) + + result, err := decodeParts(raw, "") + if err != nil { + t.Fatal(err) + } + + if !result.userFinish { + t.Fatalf("userFinish = false, want true for finish.reason=stop") + } + + if result.text != "ship the fix" { + t.Fatalf("text = %q", result.text) + } +} + +// TestDecodePartsFinishOtherReasonsIgnored verifies only finish/stop +// triggers the user-message mark — other reasons (length, tool_use, +// safety) do not. +func TestDecodePartsFinishOtherReasonsIgnored(t *testing.T) { + for _, reason := range []string{"length", "tool_use", "safety", "unknown_future_reason"} { + t.Run(reason, func(t *testing.T) { + raw := writeParts(t, + map[string]any{"type": "text", "data": map[string]any{"text": "irrelevant"}}, + map[string]any{"type": "finish", "data": map[string]any{"reason": reason}}, + ) + + result, err := decodeParts(raw, "") + if err != nil { + t.Fatal(err) + } + + if result.userFinish { + t.Fatalf("userFinish = true for reason %q, want false", reason) + } + }) + } +} + +// TestDecodePartsShellCommandEmitsExecEvent verifies the parser +// converts a shell_command part into a bash exec event with the +// command, output, and exit code. This surfaces bang-mode commands +// in the timeline. +func TestDecodePartsShellCommandEmitsExecEvent(t *testing.T) { + raw := writeParts(t, + map[string]any{"type": "shell_command", "data": map[string]any{ + "command": "ls -la", + "output": "total 0\n", + "exit_code": 0, + }}, + ) + + result, err := decodeParts(raw, "") + if err != nil { + t.Fatal(err) + } + + if len(result.events) != 1 { + t.Fatalf("events = %d, want 1 (shell_command → bash exec)", len(result.events)) + } + + if result.events[0].Name != "bash" { + t.Fatalf("tool name = %q, want bash", result.events[0].Name) + } + + if result.events[0].Input["command"] != "ls -la" { + t.Fatalf("command = %v", result.events[0].Input["command"]) + } + + if len(result.results) != 1 || result.results[0].Content != "total 0\n" { + t.Fatalf("results = %+v", result.results) + } + + if result.results[0].IsError { + t.Fatalf("exit code 0 should not be an error") + } +} + +// TestDecodePartsShellCommandDeduplicatedWithBash verifies that when +// a message has both a bash tool call and a shell_command part for +// the same command, only one event is emitted (the tool call wins). +func TestDecodePartsShellCommandDeduplicatedWithBash(t *testing.T) { + raw := writeParts(t, + map[string]any{"type": "tool_call", "data": map[string]any{ + "id": "call-1", "name": "bash", "input": `{"command":"ls -la"}`, + }}, + map[string]any{"type": "tool_result", "data": map[string]any{ + "tool_call_id": "call-1", "content": "total 0\n", + }}, + map[string]any{"type": "shell_command", "data": map[string]any{ + "command": "ls -la", + "output": "total 0\n", + "exit_code": 0, + }}, + ) + + result, err := decodeParts(raw, "") + if err != nil { + t.Fatal(err) + } + + if len(result.events) != 1 { + t.Fatalf("events = %d, want 1 (deduplicated)", len(result.events)) + } + + if result.events[0].ID != "call-1" { + t.Fatalf("event ID = %q, want call-1 (tool call wins)", result.events[0].ID) + } +} + +// TestDecodePartsImageAndBinaryAreDropped verifies image_url and +// binary parts decode without error but produce no events or text. +// The visualizer renders file edits, not base64 previews. +func TestDecodePartsImageAndBinaryAreDropped(t *testing.T) { + raw := writeParts( + t, + map[string]any{"type": "image_url", "data": map[string]any{"url": "data:image/png;base64,iVBORw0K"}}, + map[string]any{ + "type": "binary", + "data": map[string]any{"data": "AAA=", "mime_type": "application/octet-stream"}, + }, + ) + + result, err := decodeParts(raw, "") + if err != nil { + t.Fatal(err) + } + + if len(result.events) != 0 || result.text != "" { + t.Fatalf("image/binary should produce no output, got %+v", result) + } +} + +// TestDecodePartsUnknownTypeIgnored guards the "schema bump does not +// crash older mindwalk" contract. A part with an unknown discriminator +// is silently dropped. +func TestDecodePartsUnknownTypeIgnored(t *testing.T) { + raw := writeParts(t, + map[string]any{"type": "totally_new_part", "data": map[string]any{"anything": "goes"}}, + map[string]any{"type": "text", "data": map[string]any{"text": "survives"}}, + ) + + result, err := decodeParts(raw, "") + if err != nil { + t.Fatal(err) + } + + if result.text != "survives" { + t.Fatalf("text after unknown part = %q", result.text) + } +} + +// TestDecodePartsEmptyAndNullParts ensures the parser's short +// circuits for empty / null data don't crash and produce an empty +// result. +func TestDecodePartsEmptyAndNullParts(t *testing.T) { + cases := []struct { + name string + raw string + }{ + {"empty", ""}, + {"whitespace", " \n"}, + {"empty array", "[]"}, + {"null data", `[{"type":"text","data":null}]`}, + {"missing type", `[{"data":{"text":"no type"}}]`}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + result, err := decodeParts(c.raw, "") + if err != nil { + t.Fatal(err) + } + + if result.text != "" || len(result.events) != 0 { + t.Fatalf("expected empty result, got %+v", result) + } + }) + } +} + +// TestDecodePartsToolCallWithoutIDDropped verifies a malformed +// tool_call (missing id or name) is dropped instead of crashing the +// whole trace. Crush occasionally writes such rows mid-write. +func TestDecodePartsToolCallWithoutIDDropped(t *testing.T) { + cases := []map[string]any{ + {"type": "tool_call", "data": map[string]any{"name": "view", "input": "{}"}}, + {"type": "tool_call", "data": map[string]any{"id": "missing-name", "input": "{}"}}, + } + + encoded, err := json.Marshal(cases) + if err != nil { + t.Fatal(err) + } + + result, err := decodeParts(string(encoded), "") + if err != nil { + t.Fatal(err) + } + + if len(result.events) != 0 { + t.Fatalf("malformed tool_call should be dropped, got %+v", result.events) + } +} + +// TestDecodePartsToolResultWithoutIDDropped verifies a tool_result +// with no tool_call_id is dropped (orphan) instead of poisoning the +// pending calls. +func TestDecodePartsToolResultWithoutIDDropped(t *testing.T) { + raw := writeParts(t, + map[string]any{"type": "tool_result", "data": map[string]any{"content": "stray"}}, + ) + + result, err := decodeParts(raw, "") + if err != nil { + t.Fatal(err) + } + + if len(result.events) != 0 || len(result.results) != 0 { + t.Fatalf("expected empty result, got %+v", result) + } +} + +// TestDecodePartsToolCallIDCollision verifies that two tool calls +// with the same id collapse into one event. The parser keeps the +// most recent call's input and discards the older copy — the +// behaviour is intentional because the second call represents the +// authoritative "this is what the agent finally asked for". Crush +// can emit duplicate ids across messages when the cursor replays a +// row; the parser must not silently duplicate events. +func TestDecodePartsToolCallIDCollision(t *testing.T) { + raw := writeParts(t, + map[string]any{"type": "tool_call", "data": map[string]any{ + "id": "shared", "name": "view", "input": `{"file_path":"a.go"}`, + }}, + map[string]any{"type": "tool_call", "data": map[string]any{ + "id": "shared", "name": "view", "input": `{"file_path":"b.go"}`, + }}, + map[string]any{"type": "tool_result", "data": map[string]any{ + "tool_call_id": "shared", "content": "second result", + }}, + ) + + result, err := decodeParts(raw, "") + if err != nil { + t.Fatal(err) + } + + if len(result.events) != 1 { + t.Fatalf("events = %d, want 1 (id collision merges)", len(result.events)) + } + + if result.events[0].Input["file_path"] != "b.go" { + t.Fatalf("latest call wins, got file_path = %q", result.events[0].Input["file_path"]) + } + + if len(result.results) != 1 || result.results[0].Content != "second result" { + t.Fatalf("results = %+v", result.results) + } +} + +// TestParseCrushInputNestedString covers the frequent Crush shape +// where tool_call.input is a JSON literal of another JSON object. +// The parser must peel the wrapping string and recover the object. +func TestParseCrushInputNestedString(t *testing.T) { + // Single-layer nesting: the value stored in the SQLite column + // is the literal JSON string `"{...}"`. json.Unmarshal of that + // returns a Go string, which the parser then re-parses to + // recover the inner object. + inner := `{"file_path":"internal/server/server.go","limit":50}` + + wrapped, err := json.Marshal(inner) + if err != nil { + t.Fatal(err) + } + + got := parseCrushInput(string(wrapped)) + if got["file_path"] != "internal/server/server.go" { + t.Fatalf("nested JSON not peeled, got %+v", got) + } +} + +// TestParseCrushInputMalformedFallsBackToRaw covers the malformed +// input case: the parser must not panic and must still produce a +// map[string]any containing the original raw string under "_raw". +func TestParseCrushInputMalformedFallsBackToRaw(t *testing.T) { + raw := "not json {" + + got := parseCrushInput(raw) + if got["_raw"] != raw { + t.Fatalf("expected _raw = %q, got %+v", raw, got) + } +} + +// TestAgentLabelFromInputPrefersTitle verifies the label extraction +// prefers task_title over description over prompt over message. +func TestAgentLabelFromInputPrefersTitle(t *testing.T) { + cases := []struct { + name string + input map[string]any + want string + }{ + {"task_title", map[string]any{"task_title": "first"}, "first"}, + {"title", map[string]any{"title": "second"}, "second"}, + {"description", map[string]any{"description": "third"}, "third"}, + {"prompt", map[string]any{"prompt": "fourth"}, "fourth"}, + {"message", map[string]any{"message": "fifth"}, "fifth"}, + {"empty", map[string]any{"message": " "}, ""}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got := agentLabelFromInput(c.input) + if got != c.want { + t.Fatalf("got %q, want %q", got, c.want) + } + }) + } +} + +// TestSplitAgentIDRoundTrip verifies the agent-tool id parser +// recovers the message id and tool call id from a `$$`-joined value +// and rejects malformed values. +func TestSplitAgentIDRoundTrip(t *testing.T) { + msg, call, ok := splitAgentID("msg-1$$call-1") + if !ok || msg != "msg-1" || call != "call-1" { + t.Fatalf("split = (%q, %q, %v)", msg, call, ok) + } + + bad := []string{"", "$$", "a$$", "$$b", "no-separator"} + for _, b := range bad { + if _, _, ok := splitAgentID(b); ok { + t.Fatalf("expected false for %q", b) + } + } +} + +// TestSplitSessionIDAcceptsBothFormats covers both the bare-id form +// and the crush://session/ synthetic form, returning the +// underlying session id either way. +func TestSplitSessionIDAcceptsBothFormats(t *testing.T) { + for _, input := range []string{"abc-123", "crush://session/abc-123"} { + id, _, ok := splitSessionID(input) + if !ok || id != "abc-123" { + t.Fatalf("input %q -> (%q, %v)", input, id, ok) + } + } + + if _, _, ok := splitSessionID(""); ok { + t.Fatalf("empty input should fail") + } +} + +// TestSessionPathRoundTrip verifies the typed synthetic-path +// helpers in the adapter package. The scheme is shared with the +// server (which calls IsSessionPath), so the constant must stay +// stable. +func TestSessionPathRoundTrip(t *testing.T) { + for _, id := range []string{"abc-123", "msg-1$$toolcall-1", "with/slash"} { + got := SessionPath(id) + if !IsSessionPath(got) { + t.Fatalf("IsSessionPath(%q) = false", got) + } + + if extracted := SessionIDFromPath(got); extracted != id { + t.Fatalf("SessionIDFromPath(%q) = %q, want %q", got, extracted, id) + } + } + + if IsSessionPath("not a crush path") { + t.Fatalf("non-crush path should fail IsSessionPath") + } + // SessionIDFromPath returns the input unchanged when the scheme + // does not match — useful for callers that pass bare ids. + if got := SessionIDFromPath("bare-id"); got != "bare-id" { + t.Fatalf("SessionIDFromPath without scheme = %q, want bare-id", got) + } +} diff --git a/internal/adapter/crush/projects.go b/internal/adapter/crush/projects.go new file mode 100644 index 0000000..5f00e68 --- /dev/null +++ b/internal/adapter/crush/projects.go @@ -0,0 +1,125 @@ +package crush + +import ( + "encoding/json" + "os" + "path/filepath" + "sync" +) + +// crushProjectEntry is one row in the Crush projects registry. +type crushProjectEntry struct { + Path string `json:"path"` + DataDir string `json:"data_dir"` + LastAccessed string `json:"last_accessed"` +} + +// crushProjectsFile is the JSON shape of ~/.local/share/crush/projects.json. +type crushProjectsFile struct { + Projects []crushProjectEntry `json:"projects"` +} + +// projectDB pairs a project's database path with its working-directory +// path so callers that open a crush.db can recover the project root +// the session ran in. +type projectDB struct { + DBPath string + ProjectPath string +} + +// loadProjectDBs reads the Crush projects registry and returns every +// project's crush.db path that exists on disk, de-duplicated. The +// registry lives at /projects.json; when it is +// missing or unreadable the result is nil (the caller falls back to +// the single resolved database). +func loadProjectDBs() []projectDB { + registry := filepath.Join(DefaultDir(), "projects.json") + + data, err := os.ReadFile(registry) + if err != nil { + return nil + } + + var file crushProjectsFile + if err := json.Unmarshal(data, &file); err != nil { + return nil + } + + var dbs []projectDB + + seen := map[string]bool{} + + for _, p := range file.Projects { + if p.DataDir == "" { + continue + } + + dbPath := filepath.Join(p.DataDir, dbName) + if seen[dbPath] { + continue + } + + if info, err := os.Stat(dbPath); err == nil && !info.IsDir() && info.Size() > 0 { + seen[dbPath] = true + dbs = append(dbs, projectDB{DBPath: dbPath, ProjectPath: p.Path}) + } + } + + return dbs +} + +// projectPathStore caches dbPath → projectPath, populated once from +// the Crush projects registry on first use. It lives on the Adapter +// so each instance has its own cache, isolating tests. +type projectPathStore struct { + cache sync.Map // dbPath (string) → projectPath (string) + once sync.Once +} + +func (ps *projectPathStore) init() { + for _, pdb := range loadProjectDBs() { + ps.cache.Store(pdb.DBPath, pdb.ProjectPath) + } +} + +// projectPathForDB resolves the project working directory that owns the +// given crush.db path. It consults the adapter's projects.json cache +// when available, then falls back to deriving the path when the +// database lives inside a conventional /.crush/ data +// directory. Returns "" when neither yields a result, or when the only +// candidate is the global data directory (sessions there belong to +// Crush itself, not a user repo). +func (a Adapter) projectPathForDB(dbPath string) string { + if dbPath == "" { + return "" + } + + if a.projects != nil { + a.projects.once.Do(a.projects.init) + + if v, ok := a.projects.cache.Load(dbPath); ok { + if s, ok := v.(string); ok && s != "" { + return s + } + } + } + + return inferProjectPath(dbPath) +} + +// inferProjectPath derives the project working directory from a +// conventional /.crush/crush.db path. Returns "" when the +// path does not match the convention or points at the global data dir. +func inferProjectPath(dbPath string) string { + dataDir := filepath.Dir(dbPath) + if filepath.Base(dataDir) != dataDirName { + return "" + } + + projectDir := filepath.Dir(dataDir) + if globalDir := DefaultDir(); globalDir != "" && projectDir == globalDir { + return "" + } + + return projectDir +} diff --git a/internal/adapter/crush/sessions.go b/internal/adapter/crush/sessions.go new file mode 100644 index 0000000..5d9c2e7 --- /dev/null +++ b/internal/adapter/crush/sessions.go @@ -0,0 +1,923 @@ +package crush + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "slices" + "sort" + "strings" + "time" + + crushdata "github.com/LarsArtmann/go-crush-data" + "github.com/cosmtrek/mindwalk/internal/adapter" + "github.com/cosmtrek/mindwalk/internal/judge" + "github.com/cosmtrek/mindwalk/internal/model" +) + +// harnessName is the canonical harness identifier mindwalk attaches to +// every trace that came through this adapter. The frontend matches on +// this string in a few places (filter pills, panel headers, judge +// input shape) so it must stay stable. +const harnessName = "crush" + +// ListSessions returns the metadata for every top-level Crush session +// found across all known project databases, newest first. In +// auto-discover mode (Dir == "") it reads the Crush projects registry +// (~/.local/share/crush/projects.json) and queries every project's +// crush.db. When Dir is set explicitly only that one database is used. +// +// Auxiliary (sub-agent) sessions are intentionally hidden from the rail +// because the Agent Lens surfaces them on demand from a root session. +func (a Adapter) ListSessions() ([]model.SessionMeta, error) { + if a.Dir == "" { + return a.listAllProjectSessions() + } + + return a.listSingleDB() +} + +// allProjectDBs returns every known Crush database, including the +// global database when it exists. De-duplicated. Used by every multi-DB +// query so the enumeration logic lives in one place. +func allProjectDBs() []projectDB { + dbs := loadProjectDBs() + // Also include the global database (it may not appear in the + // projects registry, e.g. the global config session). + globalDB := filepath.Join(DefaultDir(), dataDirName, dbName) + if _, err := os.Stat(globalDB); err == nil { + if !slices.ContainsFunc(dbs, func(p projectDB) bool { return p.DBPath == globalDB }) { + dbs = append(dbs, projectDB{DBPath: globalDB}) + } + } + + return dbs +} + +// listAllProjectSessions scans every project database listed in the +// Crush registry and merges the results. Session IDs are globally +// unique UUIDs, so cross-database collisions do not happen in +// practice. +func (a Adapter) listAllProjectSessions() ([]model.SessionMeta, error) { + projectDBs := allProjectDBs() + if len(projectDBs) == 0 { + return nil, nil + } + + var ( + all []model.SessionMeta + oldSchema []string + missingCols []string + ) + + for _, pdb := range projectDBs { + h, err := a.openCached(pdb.DBPath) + if err != nil || h == nil { + continue + } + + if missing := h.missingColumns(); len(missing) > 0 && a.recordOldSchema(pdb.DBPath) { + oldSchema = append(oldSchema, pdb.DBPath) + missingCols = unionStrings(missingCols, missing) + } + + sessions, err := h.inner.Sessions(context.Background(), crushdata.SessionFilter{RootOnly: true}) + if err != nil { + _ = h.close() + + continue + } + + cwd := pdb.ProjectPath + if cwd == "" { + cwd = a.projectPathForDB(pdb.DBPath) + } + + for _, cs := range sessions { + meta := sessionMeta(cs) + + meta.Cwd = cwd + if a.dbIndex != nil { + a.dbIndex.Store(meta.ID, pdb.DBPath) + } + + all = append(all, meta) + } + + _ = h.close() + } + + if len(oldSchema) > 0 { + a.reportOldSchemaSummary(oldSchema, missingCols) + } + + sort.Slice(all, func(i, j int) bool { + return all[i].EndedAt > all[j].EndedAt + }) + + return all, nil +} + +func (a Adapter) listSingleDB() ([]model.SessionMeta, error) { + h, err := a.openReadOnly() + if err != nil { + return nil, err + } + + if h == nil { + return nil, nil + } + defer h.closeDiscard() + + a.warnIfOldSchema(h) + cwd := a.projectPathForDB(h.path) + + sessions, err := h.inner.Sessions(context.Background(), crushdata.SessionFilter{RootOnly: true}) + if err != nil { + return nil, fmt.Errorf("list crush sessions: %w", err) + } + + metas := make([]model.SessionMeta, 0, len(sessions)) + for _, cs := range sessions { + meta := sessionMeta(cs) + meta.Cwd = cwd + metas = append(metas, meta) + } + + sort.Slice(metas, func(i, j int) bool { + return metas[i].EndedAt > metas[j].EndedAt + }) + + return metas, nil +} + +// Summarize returns the metadata for one session without materialising +// its full message history. The agent-tool (sub-agent) session ID +// format `messageID$$toolCallID` is detected here so the rail can hide +// the corresponding rows from the root listing. +func (a Adapter) Summarize(path string) (model.SessionMeta, error) { + h, err := a.openDBForPath(path) + if err != nil { + return model.SessionMeta{}, err + } + + if h == nil { + // No database exists anywhere this adapter can reach, so the + // session cannot be recognized — report it as foreign rather + // than as a broken configuration. + return model.SessionMeta{}, adapter.NotRecognizedErr("Crush", path) + } + defer h.closeDiscard() + + id, isAgent, ok := splitSessionID(path) + if !ok { + return model.SessionMeta{}, fmt.Errorf("invalid Crush session id in path %q", path) + } + + cs, err := h.inner.Session(context.Background(), id) + if err != nil { + if errors.Is(err, crushdata.ErrSessionNotFound) { + return model.SessionMeta{}, adapter.NotRecognizedErr("Crush", path) + } + + return model.SessionMeta{}, err + } + + meta := sessionMeta(cs) + + meta.Cwd = a.projectPathForDB(h.path) + if isAgent || meta.Agent != nil { + meta.Auxiliary = true + } + + if isAgent { + parts := strings.SplitN(id, agentIDSeparator, 2) + if len(parts) == 2 { + if meta.Agent == nil { + meta.Agent = &model.AgentSessionMeta{} + } + + meta.Agent.SourceID = parts[0] + meta.Agent.LaunchCallID = parts[1] + // RootSessionID is the parent session id, not the + // parent message id. sessionMeta already populated + // it from parent_session_id; only fall back to the + // source-id split when the row had no parent row. + if meta.Agent.RootSessionID == "" { + meta.Agent.RootSessionID = parts[0] + } + } + } + // Sessions whose cwd matches the mindwalk judge workdir are mindwalk + // itself recording its judge runs — drop them from every listing. + if judge.IsWorkDir(meta.Cwd) { + meta.Auxiliary = true + } + + adapter.FallbackSessionTitle(&meta, path) + + return meta, nil +} + +// Parse converts one Crush session into the shared model.Trace. The +// implementation streams the session's messages once via the +// go-crush-data SDK, folds each message's decoded parts in declaration +// order, and emits one model.Event per resolved tool call. User +// messages with finish.reason=stop also produce a user-message mark so +// the judge layer sees the same task text the rail does. +func (a Adapter) Parse(path string) (*model.Trace, error) { + h, err := a.openDBForPath(path) + if err != nil { + return nil, err + } + + if h == nil { + return nil, errDBUnavailable + } + defer h.closeDiscard() + + id, _, ok := splitSessionID(path) + if !ok { + return nil, fmt.Errorf("invalid Crush session id in path %q", path) + } + + trace := &model.Trace{ + Version: 1, + Session: model.TraceSession{ + ID: id, + Harness: harnessName, + Path: path, + }, + Events: []model.Event{}, + Marks: []model.Mark{}, + } + + cs, err := h.inner.Session(context.Background(), id) + if err != nil { + if errors.Is(err, crushdata.ErrSessionNotFound) { + return nil, adapter.NotRecognizedErr("Crush", path) + } + + return nil, err + } + + meta := sessionMeta(cs) + applySessionMeta(trace, meta) + trace.Session.Cwd = a.projectPathForDB(h.path) + + recognized := meta.ID != "" + pending := map[string]adapter.ToolCall{} + pendingOrder := []string{} + results := map[string]adapter.ToolResult{} + prevModel := "" + prevProvider := "" + + // Messages stream one at a time: the cross-message tool_call / + // tool_result pairing below resolves through the pending and + // results maps, so no earlier message is ever revisited and only + // one decoded message is live at once — huge sessions stay flat + // in memory. + for msg, err := range h.inner.IterMessages(context.Background(), id) { + if err != nil { + return nil, fmt.Errorf("read crush messages: %w", err) + } + recognized = true + ts := timeToRFC3339(msg.CreatedAt) + applyMessageMeta(trace, msg.Model, msg.Provider, ts) + + // Track model/provider switches mid-session. + if msg.Model != "" && prevModel != "" && msg.Model != prevModel { + trace.Marks = append(trace.Marks, model.Mark{ + Seq: len(pendingOrder), + Type: "model-switch", + Note: fmt.Sprintf("%s → %s", prevModel, msg.Model), + }) + } + + if msg.Provider != "" && prevProvider != "" && msg.Provider != prevProvider { + trace.Marks = append(trace.Marks, model.Mark{ + Seq: len(pendingOrder), + Type: "model-switch", + Note: fmt.Sprintf("%s → %s", prevProvider, msg.Provider), + }) + } + + if msg.Model != "" { + prevModel = msg.Model + } + + if msg.Provider != "" { + prevProvider = msg.Provider + } + + // Malformed parts payloads arrive as nil Parts — the SDK + // already refused to decode them, so the message folds to + // an empty result and the trace keeps going. + parsed := foldParts(msg.Parts, ts) + + // User messages with finish.reason=stop represent the user + // turn boundary in Crush. The judge needs this so it can see + // what task the user actually typed without scanning the + // assistant's replies for the same shape. + if msg.Role == crushdata.RoleUser && parsed.userFinish && parsed.text != "" { + trace.Marks = append(trace.Marks, model.Mark{ + Seq: len(pendingOrder), + Type: "user-message", + Note: adapter.UserMessageNote(parsed.text), + }) + } + + if parsed.subagent && msg.Role == crushdata.RoleAssistant { + note := parsed.subagentNote + if note == "" { + note = parsed.text + } + + trace.Marks = append(trace.Marks, model.Mark{ + Seq: len(pendingOrder), + Type: "subagent", + Note: note, + }) + } + + // Reasoning marks surface the agent's inner monologue with + // duration so the timeline can show thinking vs acting phases. + if parsed.reasoningText != "" { + duration := parsed.reasoningSecs + // Fall back to the message-level wall-clock duration when + // the reasoning part doesn't carry its own timestamps. + if duration == 0 && !msg.FinishedAt.IsZero() && msg.FinishedAt.After(msg.CreatedAt) { + duration = int64(msg.FinishedAt.Sub(msg.CreatedAt) / time.Second) + } + + note := truncateNote(parsed.reasoningText, 200) + if duration > 0 { + note = fmt.Sprintf("thinking %ds: %s", duration, note) + } else { + note = "thinking: " + note + } + + trace.Marks = append(trace.Marks, model.Mark{ + Seq: len(pendingOrder), + Type: "thinking", + Note: note, + Duration: int(duration), + }) + } + + // Non-normal finish reasons are quality signals — error, + // content_filter, canceled, max_tokens — and get a mark so + // the timeline shows why a turn ended unexpectedly. + switch parsed.finishReason { + case "error", "content_filter", "canceled", "max_tokens": + note := parsed.finishReason + if parsed.finishMessage != "" { + note = fmt.Sprintf("%s: %s", parsed.finishReason, truncateNote(parsed.finishMessage, 200)) + } + + mk := model.Mark{ + Seq: len(pendingOrder), + Type: "finish-reason", + Note: note, + } + if !msg.FinishedAt.IsZero() && msg.FinishedAt.After(msg.CreatedAt) { + mk.Duration = int(msg.FinishedAt.Sub(msg.CreatedAt) / time.Second) + } + + trace.Marks = append(trace.Marks, mk) + } + + for _, call := range parsed.events { + if _, exists := pending[call.ID]; !exists { + pendingOrder = append(pendingOrder, call.ID) + } + + pending[call.ID] = call + // Match each event to its result by ToolCallID rather than + // array index. Index alignment assumes every result lives + // in the same message as its call; when Crush splits a + // tool_call and tool_result across messages, the orphan + // results are appended under p.resultOrder in declaration + // order and partsParser keeps them at the tail of + // finishResult.results — looking up by ID covers both shapes. + results[call.ID] = resultFor(parsed.results, call.ID) + } + // Fold cross-message results whose originating call lives in + // an earlier message: the per-call loop above only sees + // events from THIS message, so any result left in + // parsed.results without a matching in-message call would + // otherwise stay unpaired and ComputeStats would mark every + // such event OutcomeKnown=false. + for _, crossMessageResult := range parsed.results { + if crossMessageResult.ToolCallID == "" { + continue + } + + if _, seen := pending[crossMessageResult.ToolCallID]; !seen { + continue + } + + if existing, ok := results[crossMessageResult.ToolCallID]; ok && existing.OutcomeKnown { + continue + } + + results[crossMessageResult.ToolCallID] = crossMessageResult + } + } + + for _, id := range pendingOrder { + call := pending[id] + result := results[id] + trace.Events = append(trace.Events, adapter.BuildEvent(trace, call, result)) + } + + sort.SliceStable(trace.Events, func(i, j int) bool { + return trace.Events[i].Seq < trace.Events[j].Seq + }) + + for i := range trace.Events { + trace.Events[i].Seq = i + } + + trace.Session.EventCount = len(trace.Events) + adapter.FallbackTraceSessionTitle(trace, path) + // Read the read_files table for exact read observability. The + // table arrived in a later Crush migration; databases that predate + // it return an empty set and reads stay estimated. + readPaths := a.queryReadFiles(h, id) + if len(readPaths) > 0 { + for i := range trace.Events { + for j := range trace.Events[i].Targets { + t := &trace.Events[i].Targets[j] + if t.Touch == "read" && readPaths[t.Path] { + t.Weak = false + } + } + } + } + + readsGrade := model.ObservabilityEstimated + if len(readPaths) > 0 { + readsGrade = model.ObservabilityExact + } + // Crush's finish reasons carry no observability flags; the visualizer + // infers failures from tool_result.is_error the same way it does for + // Claude Code and Codex. + trace.Stats = model.ComputeStats( + trace, + 0, + model.ObservabilitySignals{Errors: model.ObservabilityExact, Reads: readsGrade}, + ) + + if !recognized { + return nil, adapter.NotRecognizedErr("Crush", path) + } + + return trace, nil +} + +// resultFor finds the ToolResult whose ToolCallID matches toolCallID in +// a parsed message's results slice, returning an empty ToolResult when +// no result was paired. The crush adapter pairs results by ID rather +// than by parallel-slice index because Crush splits tool_call and +// tool_result parts across messages — index alignment would silently +// drop every cross-message outcome. +func resultFor(results []adapter.ToolResult, toolCallID string) adapter.ToolResult { + for _, r := range results { + if r.ToolCallID == toolCallID { + return r + } + } + + return adapter.ToolResult{} +} + +// queryReadFiles returns the set of file paths the agent actually +// opened, according to the read_files table. Databases that predate +// the table (or fail to answer) yield nil. +func (a Adapter) queryReadFiles(h *dbHandle, sessionID string) map[string]bool { + paths, err := h.inner.ReadFiles(context.Background(), sessionID) + if err != nil { + return nil + } + + set := make(map[string]bool, len(paths)) + for _, p := range paths { + set[p] = true + } + + return set +} + +// openReadOnly opens the database in read-only mode, returning (nil, +// nil) when the file is absent so ListSessions can return an empty +// catalog without treating "no Crush installed" as an error. Other +// errors (permission denied, corrupt file, schema mismatch) are +// surfaced with the underlying cause and the resolved path so a +// user can fix the configuration without re-deriving what went +// wrong. +func (a Adapter) openReadOnly() (*dbHandle, error) { + path := a.dbPath() + if path == "" { + return nil, nil + } + + return a.openCached(path) +} + +// openCached opens a database at path, using the adapter's dbCache +// when available. Cached handles are kept open for reuse across +// requests; their close() is a no-op so the connection survives. +func (a Adapter) openCached(path string) (*dbHandle, error) { + if a.dbCache != nil { + if v, ok := a.dbCache.Load(path); ok { + if db, ok := v.(*crushdata.DB); ok && db != nil { + return &dbHandle{path: path, inner: db, cached: true}, nil + } + } + } + + h, err := openAt(path) + if err != nil || h == nil { + return h, err + } + + if a.dbCache != nil { + a.dbCache.Store(path, h.inner) + h.cached = true + } + + return h, nil +} + +// warnIfOldSchema checks whether the database is missing any of the +// well-known columns and prints a deduplicated stderr warning. Returns +// true when the schema is old (columns missing). +func (a Adapter) warnIfOldSchema(h *dbHandle) bool { + if h == nil || h.path == "" { + return false + } + + missing := h.missingColumns() + if len(missing) == 0 { + return false + } + + if !a.recordOldSchema(h.path) { + return true + } + + a.reportOldSchemaSummary([]string{h.path}, missing) + + return true +} + +// recordOldSchema marks path as having been warned about. It returns +// false when the path was already recorded, so callers can avoid +// duplicate warnings. +func (a Adapter) recordOldSchema(path string) bool { + if a.warnedOldSchema == nil { + return true + } + + if _, already := a.warnedOldSchema.LoadOrStore(path, true); already { + return false + } + + return true +} + +// reportOldSchemaSummary prints a single stderr warning for one or more +// databases with an old schema. Multiple paths are summarized so a +// host-wide scan does not flood the terminal. +func (a Adapter) reportOldSchemaSummary(paths []string, missing []string) { + if len(paths) == 0 { + return + } + + cols := strings.Join(missing, ", ") + if len(paths) == 1 { + fmt.Fprintf( + os.Stderr, + "mindwalk: warning: %s has an old schema (missing %s); upgrade Crush to get full trace coverage\n", + paths[0], + cols, + ) + + return + } + + fmt.Fprintf( + os.Stderr, + "mindwalk: warning: %d Crush databases have an old schema (missing %s); upgrade Crush to get full trace coverage", + len(paths), + cols, + ) + + n := min(3, len(paths)) + if n > 0 { + fmt.Fprintf(os.Stderr, " (e.g. %s", paths[0]) + + for i := 1; i < n; i++ { + fmt.Fprintf(os.Stderr, ", %s", paths[i]) + } + + if len(paths) > n { + fmt.Fprint(os.Stderr, ", ...") + } + + fmt.Fprint(os.Stderr, ")") + } + + fmt.Fprintln(os.Stderr) +} + +// unionStrings returns the union of a and b preserving order. +func unionStrings(a, b []string) []string { + seen := make(map[string]bool, len(a)) + for _, s := range a { + seen[s] = true + } + + for _, s := range b { + if !seen[s] { + seen[s] = true + a = append(a, s) + } + } + + return a +} + +// enumerateDBPaths returns every database path the adapter should +// consult for a multi-database scan. In explicit-Dir mode it returns +// the single configured database; in auto-discover mode it returns +// every project database plus the global one. +func (a Adapter) enumerateDBPaths() []string { + if a.Dir != "" { + if p := a.dbPath(); p != "" { + return []string{p} + } + + return nil + } + + dbs := allProjectDBs() + + paths := make([]string, 0, len(dbs)) + for _, db := range dbs { + paths = append(paths, db.DBPath) + } + + return paths +} + +// openDBForPath resolves which database holds the session identified by +// path. In explicit-Dir mode it always opens the single configured +// database. In auto-discover mode it consults the sessionDBIndex built +// during ListSessions; on a cache miss it falls back to the resolved +// single database (which covers the mindwalk open/trace CLI paths that +// bypass the server's session scan). +func (a Adapter) openDBForPath(path string) (*dbHandle, error) { + if a.Dir != "" { + return a.openReadOnly() + } + + id, _, ok := splitSessionID(path) + if ok && a.dbIndex != nil { + if cached, hit := a.dbIndex.Load(id); hit { + if dbPath, ok := cached.(string); ok && dbPath != "" { + return a.openCached(dbPath) + } + } + } + + return a.openReadOnly() +} + +// sessionMeta lifts an SDK Session into the shared model and stamps +// the canonical harness key/path. The path doubles as the rail's +// deep-link handle so adapters always return the on-disk location they +// read from. +func sessionMeta(cs crushdata.Session) model.SessionMeta { + if cs.ParentSessionID != "" { + // Non-null parent_session_id marks an agent-tool session. + // The Synthesized path is the agent-tool id (messageID$$toolCallID) + // so the rail and graph builder can find it back from the + // parent's agent tool calls. + agent := &model.AgentSessionMeta{ + SourceID: cs.ParentSessionID, + RootSessionID: cs.ParentSessionID, + ParentSessionID: cs.ParentSessionID, + } + // Crush's own agent-tool session id embeds both the parent + // message id and the launching tool call id in the row's id + // column ("messageID$$toolCallID"). Capture them so the graph + // builder can match launches ↔ children by tool call id. + if messageID, callID, ok := splitAgentID(cs.ID); ok { + agent.LaunchCallID = callID + agent.SourceID = messageID + } + + return model.SessionMeta{ + Key: SessionKey(cs.ID), + ID: cs.ID, + Harness: harnessName, + Path: SessionPath(cs.ID), + Title: cs.Title, + StartedAt: timeToRFC3339(cs.CreatedAt), + EndedAt: timeToRFC3339(cs.UpdatedAt), + EventCount: cs.MessageCount, + UserTurns: 0, + Auxiliary: true, + Agent: agent, + PromptTokens: cs.PromptTokens, + CompletionTokens: cs.CompletionTokens, + Cost: cs.CostUSD, + } + } + + return model.SessionMeta{ + Key: SessionKey(cs.ID), + ID: cs.ID, + Harness: harnessName, + Path: SessionPath(cs.ID), + Title: cs.Title, + StartedAt: timeToRFC3339(cs.CreatedAt), + EndedAt: timeToRFC3339(cs.UpdatedAt), + EventCount: cs.MessageCount, + PromptTokens: cs.PromptTokens, + CompletionTokens: cs.CompletionTokens, + Cost: cs.CostUSD, + } +} + +// applySessionMeta copies the parts of sessionMeta that matter to +// the trace: title, started/ended timestamps, model (best-effort). +// Cwd is not part of Crush's session schema; Parse sets +// trace.Session.Cwd directly from the database path so path +// normalization in BuildEvent can relativise absolute tool-call paths. +func applySessionMeta(trace *model.Trace, meta model.SessionMeta) { + if meta.Title != "" { + trace.Session.Title = meta.Title + } + + if meta.StartedAt != "" { + trace.Session.StartedAt = meta.StartedAt + } + + if meta.EndedAt != "" { + trace.Session.EndedAt = meta.EndedAt + } + + if meta.Model != "" { + trace.Session.Model = meta.Model + } + + if meta.Provider != "" { + trace.Session.Provider = meta.Provider + } +} + +// applyMessageMeta updates the trace's running cwd/model window from +// one message. Only assistant messages carry a model — every other +// row's model column is empty — so the first non-empty assignment +// wins. The same pattern applies to provider. +func applyMessageMeta(trace *model.Trace, msgModel, msgProvider, ts string) { + if msgModel != "" && trace.Session.Model == "" { + trace.Session.Model = msgModel + } + + if msgProvider != "" && trace.Session.Provider == "" { + trace.Session.Provider = msgProvider + } + + if ts != "" { + if trace.Session.StartedAt == "" { + trace.Session.StartedAt = ts + } + + trace.Session.EndedAt = ts + } +} + +// timeToRFC3339 renders an SDK timestamp in the ISO-8601 UTC shape the +// rest of mindwalk expects. The zero time returns "" so an +// empty/uninitialised timestamp doesn't trip downstream parsers. +func timeToRFC3339(t time.Time) string { + if t.IsZero() { + return "" + } + + return t.UTC().Format(time.RFC3339Nano) +} + +// secondsToRFC3339 renders Crush's second-precision Unix timestamps in +// the ISO-8601 UTC shape the rest of mindwalk expects. Zero or negative +// values return "" so an empty/uninitialised timestamp doesn't trip +// downstream parsers. +// +// IMPORTANT: Crush's database migration comment says "milliseconds", but +// the trigger writes `strftime('%s', 'now')` which is Unix SECONDS. Do not +// "fix" this back to time.UnixMilli — that sends every timestamp to 1970 +// (the bug fixed in 3f547fc). The adapter trusts the actual data, not the +// misleading migration comment. +func secondsToRFC3339(s int64) string { + if s <= 0 { + return "" + } + + return time.Unix(s, 0).UTC().Format(time.RFC3339Nano) +} + +// truncateNote clips s to maxRunes runes, appending an ellipsis when +// truncation occurs. Used for mark notes that carry user text. +func truncateNote(s string, maxRunes int) string { + runes := []rune(s) + if len(runes) <= maxRunes { + return s + } + + return string(runes[:maxRunes]) + "…" +} + +// sessionPathScheme is the synthetic-path prefix the server uses to +// deep-link into a Crush session. The actual storage lives inside +// the SQLite database; the path encodes the harness + id so +// adapter.Summarize can recover the row and the Agent Lens can +// route to child sessions via the same id format Crush uses +// internally. Kept as a constant so a future rename is one line. +const sessionPathScheme = "crush://session/" + +// SessionPath returns the synthetic handle the server uses to +// deep-link into a Crush session. Callers should treat the returned +// value as opaque and use IsSessionPath / SessionIDFromPath to +// recover the underlying id. +func SessionPath(id string) string { + return sessionPathScheme + id +} + +// IsSessionPath reports whether path is a Crush session handle. +// Useful for adapter-agnostic code in the server that needs to +// distinguish a Crush synthetic path from a real on-disk file. +func IsSessionPath(path string) bool { + return strings.HasPrefix(path, sessionPathScheme) +} + +// SessionIDFromPath extracts the bare session id from a path +// produced by SessionPath. The returned id preserves Crush's +// "messageID$$toolCallID" agent-tool format verbatim — callers +// that need to detect that shape should pass the result through +// splitAgentID. +func SessionIDFromPath(path string) string { + id, ok := strings.CutPrefix(path, sessionPathScheme) + if !ok { + return path + } + + return id +} + +// splitSessionID takes a path produced by SessionPath (or the bare +// session id) and returns the id plus a flag for the agent-tool +// "messageID$$toolCallID" shape. +func splitSessionID(path string) (string, bool, bool) { + if path == "" { + return "", false, false + } + + id := SessionIDFromPath(path) + if id == "" { + return "", false, false + } + + if messageID, callID, ok := splitAgentID(id); ok { + return messageID + agentIDSeparator + callID, true, true + } + + return id, false, true +} + +// splitAgentID reports whether id looks like Crush's agent-tool +// "messageID$$toolCallID" format and returns the components when it +// does. The dollar-sign-dollar separator is unique to Crush and +// never appears in a UUID or session index, so the match is precise. +func splitAgentID(id string) (string, string, bool) { + parts := strings.SplitN(id, agentIDSeparator, 2) + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + return "", "", false + } + + return parts[0], parts[1], true +} + +const agentIDSeparator = "$$" + +// SessionKey produces the rail/trace key from the canonical session +// id. We re-export adapter.SessionKey with a fixed harness name so +// downstream code reads the same key the server uses in its catalog. +func SessionKey(id string) string { + return adapter.SessionKey(harnessName, SessionPath(id)) +} diff --git a/internal/adapter/crush/sessions_test.go b/internal/adapter/crush/sessions_test.go new file mode 100644 index 0000000..3919ab2 --- /dev/null +++ b/internal/adapter/crush/sessions_test.go @@ -0,0 +1,586 @@ +package crush + +import ( + "database/sql" + "encoding/json" + "io" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// createCrushDBAt creates a minimal Crush database at an exact path +// (not constrained to the /.crush/crush.db layout newFixtureDB +// uses), applies the schema, optionally seeds it, and returns the open +// handle. The caller is responsible for closing the handle; tests use +// t.Cleanup for that. +func createCrushDBAt(t *testing.T, dbPath string, seed func(*sql.DB)) *sql.DB { + t.Helper() + + dir := filepath.Dir(dbPath) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + + handle, err := sql.Open("sqlite", "file:"+dbPath+"?_pragma=journal_mode(WAL)") + if err != nil { + t.Fatal(err) + } + + handle.SetMaxOpenConns(1) + + schema := ` + CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, + parent_session_id TEXT, + title TEXT NOT NULL, + message_count INTEGER NOT NULL DEFAULT 0, + prompt_tokens INTEGER NOT NULL DEFAULT 0, + completion_tokens INTEGER NOT NULL DEFAULT 0, + cost REAL NOT NULL DEFAULT 0.0, + updated_at INTEGER NOT NULL, + created_at INTEGER NOT NULL, + todos TEXT + ); + CREATE TABLE IF NOT EXISTS messages ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + role TEXT NOT NULL, + parts TEXT NOT NULL DEFAULT '[]', + model TEXT, + provider TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + finished_at INTEGER + ); + ` + if _, err := handle.Exec(schema); err != nil { + _ = handle.Close() + + t.Fatal(err) + } + + if seed != nil { + seed(handle) + } + + t.Cleanup(func() { _ = handle.Close() }) + + return handle +} + +// writeProjectsRegistry writes a projects.json registry at the given +// global data dir that points to the provided project entries. +func writeProjectsRegistry(t *testing.T, globalDir string, projects []crushProjectEntry) { + t.Helper() + + regPath := filepath.Join(globalDir, "projects.json") + + data, err := json.Marshal(crushProjectsFile{Projects: projects}) + if err != nil { + t.Fatal(err) + } + + if err := os.WriteFile(regPath, data, 0o644); err != nil { + t.Fatal(err) + } +} + +// TestEnumerateDBPathsExplicitDir verifies that an adapter configured +// with an explicit Dir returns exactly one database path. +func TestEnumerateDBPathsExplicitDir(t *testing.T) { + dataDir, _ := newFixtureDB(t, nil) + a := NewAdapter(dataDir) + + paths := a.enumerateDBPaths() + if len(paths) != 1 { + t.Fatalf("expected 1 path, got %d: %v", len(paths), paths) + } + + expected := filepath.Join(dataDir, dbName) + if paths[0] != expected { + t.Fatalf("expected %s, got %s", expected, paths[0]) + } +} + +// TestEnumerateDBPathsExplicitDirMissingDB verifies that an adapter +// with an explicit Dir still returns the expected path even when the +// database file does not exist — callers handle missing files. +func TestEnumerateDBPathsExplicitDirMissingDB(t *testing.T) { + a := NewAdapter(t.TempDir()) + + paths := a.enumerateDBPaths() + if len(paths) != 1 { + t.Fatalf("expected 1 path (existence is caller's concern), got %d: %v", len(paths), paths) + } +} + +// TestEnumerateDBPathsAutoDiscover verifies that an adapter in +// auto-discover mode (empty Dir) finds every database listed in the +// Crush projects registry. +func TestEnumerateDBPathsAutoDiscover(t *testing.T) { + globalDir := t.TempDir() + t.Setenv("CRUSH_GLOBAL_DATA", globalDir) + + proj1DataDir := filepath.Join(globalDir, "proj1", dataDirName) + proj2DataDir := filepath.Join(globalDir, "proj2", dataDirName) + createCrushDBAt(t, filepath.Join(proj1DataDir, dbName), nil) + createCrushDBAt(t, filepath.Join(proj2DataDir, dbName), nil) + + writeProjectsRegistry(t, globalDir, []crushProjectEntry{ + {Path: "/repo/proj1", DataDir: proj1DataDir}, + {Path: "/repo/proj2", DataDir: proj2DataDir}, + }) + + a := NewAdapter("") + + paths := a.enumerateDBPaths() + if len(paths) != 2 { + t.Fatalf("expected 2 paths, got %d: %v", len(paths), paths) + } +} + +// TestEnumerateDBPathsAutoDiscoverIncludesGlobalDB verifies that the +// global database (next to projects.json) is included even when not +// listed in the registry. +func TestEnumerateDBPathsAutoDiscoverIncludesGlobalDB(t *testing.T) { + globalDir := t.TempDir() + t.Setenv("CRUSH_GLOBAL_DATA", globalDir) + + projDir := filepath.Join(globalDir, "proj1", dataDirName) + createCrushDBAt(t, filepath.Join(projDir, dbName), nil) + writeProjectsRegistry(t, globalDir, []crushProjectEntry{ + {Path: "/repo/proj1", DataDir: projDir}, + }) + + // Also create the global database + createCrushDBAt(t, filepath.Join(globalDir, dataDirName, dbName), nil) + + a := NewAdapter("") + + paths := a.enumerateDBPaths() + if len(paths) != 2 { + t.Fatalf("expected 2 paths (project + global), got %d: %v", len(paths), paths) + } +} + +// TestListAllProjectSessionsMultiDB verifies that sessions are +// collected from every project database and that dbIndex is populated +// so subsequent openDBForPath calls route to the correct database. +func TestListAllProjectSessionsMultiDB(t *testing.T) { + globalDir := t.TempDir() + t.Setenv("CRUSH_GLOBAL_DATA", globalDir) + + proj1DataDir := filepath.Join(globalDir, "proj1", dataDirName) + proj2DataDir := filepath.Join(globalDir, "proj2", dataDirName) + + createCrushDBAt(t, filepath.Join(proj1DataDir, dbName), func(db *sql.DB) { + insertSession(t, db, "sess-alpha", "", "Project 1 Session", time.Now(), 5) + }) + createCrushDBAt(t, filepath.Join(proj2DataDir, dbName), func(db *sql.DB) { + insertSession(t, db, "sess-beta", "", "Project 2 Session", time.Now(), 3) + }) + + writeProjectsRegistry(t, globalDir, []crushProjectEntry{ + {Path: "/repo/proj1", DataDir: proj1DataDir}, + {Path: "/repo/proj2", DataDir: proj2DataDir}, + }) + + a := NewAdapter("") + + sessions, err := a.listAllProjectSessions() + if err != nil { + t.Fatal(err) + } + + if len(sessions) != 2 { + t.Fatalf("expected 2 sessions, got %d", len(sessions)) + } + + // Verify dbIndex was populated with both session IDs + alphaDB, ok := a.dbIndex.Load("sess-alpha") + if !ok { + t.Fatal("dbIndex missing sess-alpha") + } + + if alphaDB.(string) != filepath.Join(proj1DataDir, dbName) { + t.Fatalf("sess-alpha routed to wrong DB: got %v", alphaDB) + } + + betaDB, ok := a.dbIndex.Load("sess-beta") + if !ok { + t.Fatal("dbIndex missing sess-beta") + } + + if betaDB.(string) != filepath.Join(proj2DataDir, dbName) { + t.Fatalf("sess-beta routed to wrong DB: got %v", betaDB) + } +} + +// TestOpenDBForPathRoutesToCorrectDB verifies that after +// listAllProjectSessions populates dbIndex, openDBForPath opens the +// right database for each session. +func TestOpenDBForPathRoutesToCorrectDB(t *testing.T) { + globalDir := t.TempDir() + t.Setenv("CRUSH_GLOBAL_DATA", globalDir) + + proj1DataDir := filepath.Join(globalDir, "proj1", dataDirName) + proj2DataDir := filepath.Join(globalDir, "proj2", dataDirName) + + createCrushDBAt(t, filepath.Join(proj1DataDir, dbName), func(db *sql.DB) { + insertSession(t, db, "sess-alpha", "", "Alpha", time.Now(), 1) + }) + createCrushDBAt(t, filepath.Join(proj2DataDir, dbName), func(db *sql.DB) { + insertSession(t, db, "sess-beta", "", "Beta", time.Now(), 1) + }) + + writeProjectsRegistry(t, globalDir, []crushProjectEntry{ + {Path: "/repo/proj1", DataDir: proj1DataDir}, + {Path: "/repo/proj2", DataDir: proj2DataDir}, + }) + + a := NewAdapter("") + if _, err := a.listAllProjectSessions(); err != nil { + t.Fatal(err) + } + + // Now openDBForPath should route each session to its correct DB + handle, err := a.openDBForPath(SessionPath("sess-alpha")) + if err != nil || handle == nil { + t.Fatalf("openDBForPath(sess-alpha): handle=%v err=%v", handle, err) + } + + if handle.path != filepath.Join(proj1DataDir, dbName) { + t.Fatalf("sess-alpha opened wrong DB: got %s, want %s", handle.path, filepath.Join(proj1DataDir, dbName)) + } + + _ = handle.close() + + handle2, err := a.openDBForPath(SessionPath("sess-beta")) + if err != nil || handle2 == nil { + t.Fatalf("openDBForPath(sess-beta): handle=%v err=%v", handle2, err) + } + + if handle2.path != filepath.Join(proj2DataDir, dbName) { + t.Fatalf("sess-beta opened wrong DB: got %s, want %s", handle2.path, filepath.Join(proj2DataDir, dbName)) + } + + _ = handle2.close() +} + +// TestOpenDBForPathExplicitDirIgnoresIndex verifies that an adapter +// with an explicit Dir always opens the configured database, +// regardless of what is in dbIndex. +func TestOpenDBForPathExplicitDirIgnoresIndex(t *testing.T) { + dataDir, db := newFixtureDB(t, func(db *sql.DB) { + insertSession(t, db, "sess-x", "", "Test", time.Now(), 1) + }) + _ = db + a := NewAdapter(dataDir) + + handle, err := a.openDBForPath(SessionPath("sess-x")) + if err != nil || handle == nil { + t.Fatalf("openDBForPath failed: handle=%v err=%v", handle, err) + } + + expected := filepath.Join(dataDir, dbName) + if handle.path != expected { + t.Fatalf("expected %s, got %s", expected, handle.path) + } + + _ = handle.close() +} + +// TestProjectPathForDBInfersFromConventionalPath verifies the path +// inference fallback: a DB at /.crush/crush.db resolves to +// . +func TestProjectPathForDBInfersFromConventionalPath(t *testing.T) { + tmp := t.TempDir() + dbPath := filepath.Join(tmp, "myproject", dataDirName, dbName) + got := Adapter{}.projectPathForDB(dbPath) + + expected := filepath.Join(tmp, "myproject") + if got != expected { + t.Fatalf("projectPathForDB(%q) = %q, want %q", dbPath, got, expected) + } +} + +// TestProjectPathForDBRejectsGlobalDir verifies that the global data +// directory itself returns "" (sessions there belong to Crush, not a +// user repo). +func TestProjectPathForDBRejectsGlobalDir(t *testing.T) { + globalDir := t.TempDir() + t.Setenv("CRUSH_GLOBAL_DATA", globalDir) + dbPath := filepath.Join(globalDir, dataDirName, dbName) + + got := Adapter{}.projectPathForDB(dbPath) + if got != "" { + t.Fatalf("projectPathForDB(global DB) = %q, want empty", got) + } +} + +// TestProjectPathForDBEmptyInput verifies that empty input returns "". +func TestProjectPathForDBEmptyInput(t *testing.T) { + if got := (Adapter{}).projectPathForDB(""); got != "" { + t.Fatalf("projectPathForDB(\"\") = %q, want empty", got) + } +} + +// TestWarnIfOldSchemaDetectsMissingColumns verifies that a database +// without the optional columns is detected as old-schema. +func TestWarnIfOldSchemaDetectsMissingColumns(t *testing.T) { + tmp := t.TempDir() + // The SDK opens /crush.db, so the file must carry that name. + dbPath := filepath.Join(tmp, "crush.db") + // Create a database with the required tables but none of the + // optional columns (sessions.cost/parent_session_id, + // messages.model/provider/finished_at). + dir := filepath.Dir(dbPath) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + + handle, err := sql.Open("sqlite", "file:"+dbPath+"?_pragma=journal_mode(WAL)") + if err != nil { + t.Fatal(err) + } + + handle.SetMaxOpenConns(1) + t.Cleanup(func() { _ = handle.Close() }) + + if _, err := handle.Exec(` + CREATE TABLE sessions ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + message_count INTEGER NOT NULL DEFAULT 0, + prompt_tokens INTEGER NOT NULL DEFAULT 0, + completion_tokens INTEGER NOT NULL DEFAULT 0, + updated_at INTEGER NOT NULL, + created_at INTEGER NOT NULL, + todos TEXT + ); + CREATE TABLE messages ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + role TEXT NOT NULL, + parts TEXT NOT NULL DEFAULT '[]', + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + `); err != nil { + t.Fatal(err) + } + + h, err := openAt(dbPath) + if err != nil { + t.Fatal(err) + } + + if h == nil { + t.Fatal("openAt returned nil handle for an existing database") + } + + t.Cleanup(func() { _ = h.close() }) + + a := NewAdapter("") + + missing := h.missingColumns() + if len(missing) != 5 { + t.Fatalf("expected 5 missing columns, got %d: %v", len(missing), missing) + } + + if a.warnIfOldSchema(h) != true { + t.Fatal("warnIfOldSchema should return true for old schema") + } + // Second call should be deduplicated — still returns true but + // LoadOrStore confirms the path was stored. + if _, ok := a.warnedOldSchema.Load(dbPath); !ok { + t.Fatal("warnedOldSchema should have recorded the path") + } +} + +// TestWarnIfOldSchemaSkipsGoodSchema verifies that a database with all +// well-known columns does not trigger a warning. +func TestWarnIfOldSchemaSkipsGoodSchema(t *testing.T) { + tmp := t.TempDir() + dbPath := filepath.Join(tmp, "crush.db") + + handle, err := sql.Open("sqlite", "file:"+dbPath+"?_pragma=journal_mode(WAL)") + if err != nil { + t.Fatal(err) + } + + handle.SetMaxOpenConns(1) + t.Cleanup(func() { _ = handle.Close() }) + + if _, err := handle.Exec(` + CREATE TABLE sessions ( + id TEXT PRIMARY KEY, + parent_session_id TEXT, + title TEXT NOT NULL, + message_count INTEGER NOT NULL DEFAULT 0, + prompt_tokens INTEGER NOT NULL DEFAULT 0, + completion_tokens INTEGER NOT NULL DEFAULT 0, + cost REAL NOT NULL DEFAULT 0.0, + updated_at INTEGER NOT NULL, + created_at INTEGER NOT NULL, + todos TEXT + ); + CREATE TABLE messages ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + role TEXT NOT NULL, + parts TEXT NOT NULL DEFAULT '[]', + model TEXT, + provider TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + finished_at INTEGER + ); + `); err != nil { + t.Fatal(err) + } + + h, err := openAt(dbPath) + if err != nil { + t.Fatal(err) + } + + if h == nil { + t.Fatal("openAt returned nil handle for an existing database") + } + + t.Cleanup(func() { _ = h.close() }) + + a := NewAdapter("") + + missing := h.missingColumns() + if len(missing) != 0 { + t.Fatalf("expected 0 missing columns, got %d: %v", len(missing), missing) + } + + if a.warnIfOldSchema(h) != false { + t.Fatal("warnIfOldSchema should return false for good schema") + } +} + +// captureStderr redirects os.Stderr to a pipe while f runs and returns +// everything that was written. +func captureStderr(f func()) string { + old := os.Stderr + r, w, _ := os.Pipe() + os.Stderr = w + + f() + + _ = w.Close() + out, _ := io.ReadAll(r) + os.Stderr = old + + return string(out) +} + +// TestRecordOldSchemaDedup verifies that recordOldSchema returns true the +// first time for a path and false thereafter. +func TestRecordOldSchemaDedup(t *testing.T) { + a := NewAdapter("") + if !a.recordOldSchema("/tmp/x") { + t.Fatal("first record should return true") + } + + if a.recordOldSchema("/tmp/x") { + t.Fatal("second record should return false") + } + + if !a.recordOldSchema("/tmp/y") { + t.Fatal("different path should return true") + } +} + +// TestReportOldSchemaSummarySingle verifies the single-database warning +// format. +func TestReportOldSchemaSummarySingle(t *testing.T) { + a := NewAdapter("") + out := captureStderr(func() { + a.reportOldSchemaSummary([]string{"/tmp/foo/crush.db"}, []string{"parent_session_id"}) + }) + + want := "mindwalk: warning: /tmp/foo/crush.db has an old schema (missing parent_session_id); upgrade Crush to get full trace coverage\n" + if out != want { + t.Fatalf("unexpected summary output:\n got: %q\nwant: %q", out, want) + } +} + +// TestReportOldSchemaSummaryMultiple verifies that several databases are +// collapsed into one summary line with a sample list. +func TestReportOldSchemaSummaryMultiple(t *testing.T) { + a := NewAdapter("") + paths := []string{"/tmp/a/crush.db", "/tmp/b/crush.db", "/tmp/c/crush.db", "/tmp/d/crush.db"} + out := captureStderr(func() { + a.reportOldSchemaSummary(paths, []string{"parent_session_id"}) + }) + + want := "mindwalk: warning: 4 Crush databases have an old schema (missing parent_session_id); upgrade Crush to get full trace coverage (e.g. /tmp/a/crush.db, /tmp/b/crush.db, /tmp/c/crush.db, ...)\n" + if out != want { + t.Fatalf("unexpected summary output:\n got: %q\nwant: %q", out, want) + } +} + +// TestTimestampsAreSecondsNotMillis guards against the regression fixed +// in 3f547fc: Crush stores timestamps as Unix seconds, but the adapter +// previously passed them to time.UnixMilli, sending every date to 1970. +func TestTimestampsAreSecondsNotMillis(t *testing.T) { + known := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC).Unix() + + got := secondsToRFC3339(known) + if !strings.HasPrefix(got, "2026-") { + t.Fatalf( + "secondsToRFC3339(%d) = %q, want a 2026- date (if this shows 1970-, the value is being treated as milliseconds — see 3f547fc)", + known, + got, + ) + } + + bad := time.UnixMilli(known).UTC().Format(time.RFC3339Nano) + if strings.HasPrefix(bad, "2026-") { + t.Fatalf("test invariant broken: %d via UnixMilli should land in 1970, got %q", known, bad) + } +} + +// TestTimestampsSecondsEndToEnd verifies that a session row carrying a +// known seconds timestamp surfaces as the correct date through the +// adapter's ListSessions path, not as a 1970 date. +func TestTimestampsSecondsEndToEnd(t *testing.T) { + ts := time.Date(2026, 6, 15, 12, 0, 0, 0, time.UTC).Unix() + dir := t.TempDir() + dbPath := filepath.Join(dir, "crush.db") + createCrushDBAt(t, dbPath, func(db *sql.DB) { + _, err := db.Exec( + `INSERT INTO sessions (id, title, message_count, updated_at, created_at) VALUES (?, ?, 0, ?, ?)`, + "ts-e2e", "timestamp test", ts, ts, + ) + if err != nil { + t.Fatal(err) + } + }) + + a := NewAdapter(dir) + + metas, err := a.ListSessions() + if err != nil { + t.Fatal(err) + } + + if len(metas) != 1 { + t.Fatalf("expected 1 session, got %d", len(metas)) + } + + if !strings.HasPrefix(metas[0].StartedAt, "2026-06-15") { + t.Fatalf( + "StartedAt = %q, want a 2026-06-15 date (timestamp decoded as milliseconds, not seconds — see 3f547fc)", + metas[0].StartedAt, + ) + } +} diff --git a/internal/adapter/crush/stress_test.go b/internal/adapter/crush/stress_test.go new file mode 100644 index 0000000..f09f82b --- /dev/null +++ b/internal/adapter/crush/stress_test.go @@ -0,0 +1,168 @@ +package crush + +import ( + "database/sql" + "fmt" + "testing" +) + +// TestListSessionsLargeDatabase verifies the adapter handles a database +// with many sessions without errors. This is a smoke test for scale — +// it does not assert performance, only correctness under volume. +func TestListSessionsLargeDatabase(t *testing.T) { + const sessionCount = 500 + + dir := t.TempDir() + dbPath := dir + "/crush.db" + db := createCrushDBAt(t, dbPath, func(db *sql.DB) { + for i := range sessionCount { + id := fmt.Sprintf("sess-large-%04d", i) + + _, err := db.Exec( + `INSERT INTO sessions (id, title, message_count, updated_at, created_at) VALUES (?, ?, ?, ?, ?)`, + id, fmt.Sprintf("Session %d", i), 0, 1785585600+i, 1785585600+i, + ) + if err != nil { + t.Fatal(err) + } + } + }) + _ = db + + a := NewAdapter(dir) + + metas, err := a.ListSessions() + if err != nil { + t.Fatalf("ListSessions on %d sessions: %v", sessionCount, err) + } + + if len(metas) != sessionCount { + t.Fatalf("got %d sessions, want %d", len(metas), sessionCount) + } +} + +// TestParseLargeMessageHistory verifies the adapter parses a session +// with many messages without errors or panics. +func TestParseLargeMessageHistory(t *testing.T) { + const messageCount = 1000 + + dir := t.TempDir() + dbPath := dir + "/crush.db" + createCrushDBAt(t, dbPath, func(db *sql.DB) { + _, err := db.Exec( + `INSERT INTO sessions (id, title, message_count, updated_at, created_at) VALUES ('sess-many-msgs', 'Many messages', ?, ?, ?)`, + messageCount, + 1785585600+messageCount, + 1785585600, + ) + if err != nil { + t.Fatal(err) + } + + for i := range messageCount { + msgID := fmt.Sprintf("msg-%04d", i) + + role := "assistant" + if i%2 == 0 { + role = "tool" + } + + var parts string + + if role == "assistant" { + callID := fmt.Sprintf("call_%04d", i) + parts = fmt.Sprintf( + `[{"data":{"text":"step %d"},"type":"text"},{"data":{"finished":true,"id":"%s","input":"{\"file_path\":\"/repo/file_%d.go\"}","name":"read"},"type":"tool_call"}]`, + i, + callID, + i, + ) + } else { + parts = fmt.Sprintf( + `[{"data":{"content":"ok","is_error":false,"name":"read","tool_call_id":"call_%04d"},"type":"tool_result"}]`, + i-1, + ) + } + + ts := 1785585600 + i + + _, err := db.Exec( + `INSERT INTO messages (id, session_id, role, parts, model, created_at, updated_at) VALUES (?, 'sess-many-msgs', ?, ?, 'test-model', ?, ?)`, + msgID, + role, + parts, + ts, + ts, + ) + if err != nil { + t.Fatal(err) + } + } + }) + + a := NewAdapter(dir) + + trace, err := a.Parse(SessionPath("sess-many-msgs")) + if err != nil { + t.Fatalf("Parse on %d messages: %v", messageCount, err) + } + + if trace.Session.EventCount == 0 { + t.Fatal("EventCount = 0, want > 0") + } +} + +// BenchmarkListSessionsLargeDB measures ListSessions performance with +// a large session count. Run with: go test -bench=BenchmarkListSessionsLargeDB. +func BenchmarkListSessionsLargeDB(b *testing.B) { + const sessionCount = 2000 + + dir := b.TempDir() + dbPath := dir + "/crush.db" + + db, err := sql.Open("sqlite", "file:"+dbPath) + if err != nil { + b.Fatal(err) + } + + defer func() { _ = db.Close() }() + + db.SetMaxOpenConns(1) + + schema := ` + CREATE TABLE sessions ( + id TEXT PRIMARY KEY, parent_session_id TEXT, title TEXT NOT NULL, + message_count INTEGER NOT NULL DEFAULT 0, prompt_tokens INTEGER NOT NULL DEFAULT 0, + completion_tokens INTEGER NOT NULL DEFAULT 0, cost REAL NOT NULL DEFAULT 0.0, + updated_at INTEGER NOT NULL, created_at INTEGER NOT NULL, todos TEXT + ); + CREATE TABLE messages ( + id TEXT PRIMARY KEY, session_id TEXT NOT NULL, role TEXT NOT NULL, + parts TEXT NOT NULL DEFAULT '[]', model TEXT, provider TEXT, + created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, finished_at INTEGER + ); + ` + if _, err := db.Exec(schema); err != nil { + b.Fatal(err) + } + + for i := range sessionCount { + _, err := db.Exec( + `INSERT INTO sessions (id, title, message_count, updated_at, created_at) VALUES (?, ?, 0, ?, ?)`, + fmt.Sprintf("bench-sess-%04d", i), "bench", 1785585600+i, 1785585600+i, + ) + if err != nil { + b.Fatal(err) + } + } + + a := NewAdapter(dir) + + b.ReportAllocs() + + for b.Loop() { + if _, err := a.ListSessions(); err != nil { + b.Fatal(err) + } + } +} diff --git a/internal/adapter/crush/todo_spike_test.go b/internal/adapter/crush/todo_spike_test.go new file mode 100644 index 0000000..f6f9023 --- /dev/null +++ b/internal/adapter/crush/todo_spike_test.go @@ -0,0 +1,84 @@ +//nolint:testpackage,paralleltest // See docstring below for the paralleltest exception. +package crush + +import ( + "encoding/json" + "testing" + + crushdata "github.com/LarsArtmann/go-crush-data" +) + +// TestSpikeDecodeTodosFromFixture pins T29: confirm the +// go-crush-data v0.3.0 todos decoder accepts whatever the +// committed fixture's session-level `todos` column carries. If +// this test compiles and the SDK call returns without error, +// the data path is wired and a follow-up can add a SessionMeta +// field + UI surface; if it fails, the todo column shape +// differs from what the SDK expects and the follow-up changes +// first. +// +// Note: this test writes briefly to the committed fixture DB while +// ListSessions probes its schema (see openCached path), so it runs +// serially with sibling tests to avoid "database is locked" under +// -race. Do NOT mark t.Parallel(). +func TestSpikeDecodeTodosFromFixture(t *testing.T) { + dir := fixtureDir(t) + + a := Adapter{Dir: dir} + + // ListSessions populates the dbIndex so openDBForPath can + // route each session id to its source database. + metas, err := a.ListSessions() + if err != nil { + t.Fatalf("ListSessions: %v", err) + } + + for _, meta := range metas { + path := SessionPath(meta.ID) + + handle, openErr := a.openDBForPath(path) + if openErr != nil { + t.Fatalf("openDBForPath(%s): %v", path, openErr) + } + + sess, sessionErr := handle.inner.Session(t.Context(), meta.ID) + _ = handle.close() + + if sessionErr != nil { + t.Fatalf("Session(%s): %v", meta.ID, sessionErr) + } + + raw := sess.Todos + if len(raw) == 0 { + t.Logf("session %s: no todos column data", meta.ID) + + continue + } + + todos, decodeErr := crushdata.DecodeTodos(raw) + if decodeErr != nil { + t.Logf("session %s: todos decode error: %v", meta.ID, decodeErr) + t.Logf("session %s: raw todos (first 200 bytes): %s", meta.ID, truncateForLog(raw, 200)) + + continue + } + + if len(todos) > 0 { + t.Logf("session %s: decoded %d todo entries", meta.ID, len(todos)) + + for _, todo := range todos { + t.Logf(" status=%s content=%.100q", todo.Status, todo.Content) + } + } + } +} + +// truncateForLog returns the first n bytes of raw as a string for log +// lines; the JSON column can be large, so we clip at the call site. +func truncateForLog(raw json.RawMessage, n int) string { + if len(raw) <= n { + return string(raw) + } + + return string(raw[:n]) + "..." +} diff --git a/schema/agent-graph.schema.json b/schema/agent-graph.schema.json index 3f922a1..5f352ae 100644 --- a/schema/agent-graph.schema.json +++ b/schema/agent-graph.schema.json @@ -50,6 +50,7 @@ "codex-parent-thread-id", "claude-tool-use-id", "claude-subagents-directory", + "crush-agent-id", "unavailable" ] } diff --git a/testdata/crush/build.go b/testdata/crush/build.go new file mode 100644 index 0000000..283f529 --- /dev/null +++ b/testdata/crush/build.go @@ -0,0 +1,179 @@ +//go:build ignore + +// This is a standalone fixture-builder tool for testdata/crush/crush.db. +// Run with: go run testdata/crush/build.go +// +// It recreates the committed fixture from scratch so the test database +// is no longer a black box. The schema and seed data mirror what a real +// Crush session looks like: root session, sub-agent child session, +// read/write/bash tool calls, token economics, and a read_files table. +package main + +import ( + "database/sql" + "fmt" + "os" + "path/filepath" + + _ "modernc.org/sqlite" +) + +func main() { + dir := "." + dbPath := filepath.Join(dir, "crush.db") + for _, suffix := range []string{"", "-shm", "-wal"} { + _ = os.Remove(dbPath + suffix) + } + + db, err := sql.Open("sqlite", "file:"+dbPath+"?_pragma=journal_mode(WAL)") + if err != nil { + panic(err) + } + defer func() { _ = db.Close() }() + db.SetMaxOpenConns(1) + + schema := ` + CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, + parent_session_id TEXT, + title TEXT NOT NULL, + message_count INTEGER NOT NULL DEFAULT 0, + prompt_tokens INTEGER NOT NULL DEFAULT 0, + completion_tokens INTEGER NOT NULL DEFAULT 0, + cost REAL NOT NULL DEFAULT 0.0, + updated_at INTEGER NOT NULL, + created_at INTEGER NOT NULL, + todos TEXT + ); + CREATE TABLE IF NOT EXISTS messages ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + role TEXT NOT NULL, + parts TEXT NOT NULL DEFAULT '[]', + model TEXT, + provider TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + finished_at INTEGER + ); + CREATE TABLE IF NOT EXISTS read_files ( + session_id TEXT NOT NULL, + path TEXT NOT NULL, + read_at INTEGER NOT NULL + ); + ` + if _, err := db.Exec(schema); err != nil { + panic(err) + } + + // Timestamps in Unix SECONDS (the adapter treats them as seconds per 3f547fc). + const base = 1785585600 // 2026-08-04T~12:00 UTC + + // Root session with token economics. + _, err = db.Exec( + `INSERT INTO sessions (id, parent_session_id, title, message_count, prompt_tokens, completion_tokens, cost, updated_at, created_at) + VALUES ('fixture-root', NULL, 'Fixture root session', 10, 5000, 1500, 0.0234, ?, ?)`, + base+9, base, + ) + if err != nil { + panic(err) + } + + // Child (sub-agent) session. + _, err = db.Exec( + `INSERT INTO sessions (id, parent_session_id, title, message_count, prompt_tokens, completion_tokens, cost, updated_at, created_at) + VALUES ('m_assistant_1$$call_agent_1', 'fixture-root', 'Agent: read server', 1, 0, 0, 0.0, ?, ?)`, + base+3, base+1, + ) + if err != nil { + panic(err) + } + + messages := []struct { + id, sessionID, role, parts, model string + createdAt int64 + }{ + { + "m_user", "fixture-root", "user", + `[{"data":{"text":"Read internal/server/server.go."},"type":"text"},{"data":{"reason":"stop","time":0},"type":"finish"}]`, + "", base, + }, + { + "m_assistant_1", "fixture-root", "assistant", + `[{"data":{"text":"Reading the server."},"type":"text"},{"data":{"finished":true,"id":"call_agent_1","input":"{\"task_title\":\"read server\",\"agent_type\":\"explore\",\"message\":\"look at internal/server/server.go\"}","name":"agent","provider_executed":false},"type":"tool_call"}]`, + "minimax/minimax-m3", base + 1, + }, + { + "m_tool_agent", "fixture-root", "tool", + `[{"data":{"content":"{\"agent_id\":\"m_assistant_1\",\"nickname\":\"explore\",\"task_name\":\"read server\"}","is_error":false,"name":"agent","tool_call_id":"call_agent_1"},"type":"tool_result"}]`, + "", base + 2, + }, + { + "m_assistant_2", "fixture-root", "assistant", + `[{"data":{"text":"Let me read the main file."},"type":"text"},{"data":{"finished":true,"id":"call_read_1","input":"{\"file_path\":\"/home/lars/forks/mindwalk/cmd/mindwalk/main.go\"}","name":"read","provider_executed":false},"type":"tool_call"}]`, + "minimax/minimax-m3", base + 3, + }, + { + "m_tool_read", "fixture-root", "tool", + `[{"data":{"content":"package main\n\nfunc main() {}\n","is_error":false,"name":"read","tool_call_id":"call_read_1"},"type":"tool_result"}]`, + "", base + 4, + }, + { + "m_assistant_3", "fixture-root", "assistant", + `[{"data":{"text":"Now I'll edit the server."},"type":"text"},{"data":{"finished":true,"id":"call_write_1","input":"{\"file_path\":\"/home/lars/forks/mindwalk/internal/server/server.go\"}","name":"write","provider_executed":false},"type":"tool_call"}]`, + "minimax/minimax-m3", base + 5, + }, + { + "m_tool_write", "fixture-root", "tool", + `[{"data":{"content":"File written successfully.","is_error":false,"name":"write","tool_call_id":"call_write_1"},"type":"tool_result"}]`, + "", base + 6, + }, + { + "m_assistant_4", "fixture-root", "assistant", + `[{"data":{"text":"Running tests."},"type":"text"},{"data":{"finished":true,"id":"call_bash_1","input":"{\"command\":\"go test ./internal/server/\"}","name":"bash","provider_executed":false},"type":"tool_call"}]`, + "minimax/minimax-m3", base + 7, + }, + { + "m_tool_bash", "fixture-root", "tool", + `[{"data":{"content":"ok\tgithub.com/cosmtrek/mindwalk/internal/server\t0.3s\n","is_error":false,"name":"bash","tool_call_id":"call_bash_1"},"type":"tool_result"}]`, + "", base + 8, + }, + { + "m_user_2", "fixture-root", "user", + `[{"data":{"text":"Looks good!"},"type":"text"},{"data":{"reason":"stop","time":0},"type":"finish"}]`, + "", base + 9, + }, + { + "m_child_1", "m_assistant_1$$call_agent_1", "assistant", + `[{"data":{"text":"Reading the file."},"type":"text"}]`, + "minimax/minimax-m3", base + 2, + }, + } + + for _, m := range messages { + var model any + if m.model == "" { + model = nil + } else { + model = m.model + } + _, err = db.Exec( + `INSERT INTO messages (id, session_id, role, parts, model, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)`, + m.id, m.sessionID, m.role, m.parts, model, m.createdAt, m.createdAt, + ) + if err != nil { + panic(fmt.Errorf("insert message %s: %w", m.id, err)) + } + } + + // read_files table for exact-read observability. + _, err = db.Exec( + `INSERT INTO read_files (session_id, path, read_at) VALUES ('fixture-root', '/home/lars/forks/mindwalk/cmd/mindwalk/main.go', ?)`, + 1700000000, + ) + if err != nil { + panic(err) + } + + fmt.Println("Fixture written to", dbPath) +} diff --git a/testdata/crush/crush.db b/testdata/crush/crush.db new file mode 100644 index 0000000000000000000000000000000000000000..4bbc7169bac007ab35cc743eda022986b8d9624d GIT binary patch literal 24576 zcmeI3?{Cva7{{F-O`C?+ObnsEsOlP`f>shjL7KW3qjX_H%NQ+18X}ct;%jQ+ADMlo zMO`(eFln#0SJPhZZ`k{3qT@$FyqPrZ-K4#lw6{!?(DrW6o$bU)novPsZ0K?8#6I7h zpZk0tpPlEHh1}Oq zN5sE{fdh&#S`>EdaiH>cMT}-U24t3kprT2;#=B}2Oq^ax6Q@mo+)`-C~`K@*ATe?P^ zR!Rk0N{OcI(pFx=t80CaujrQUuC`Uc80C|xBlF`iLANxr?tWsx4@5=vsLl745QS_#o64EpovxF0tZJhR4u`4}CH4>$~4Pf9vKSzf__06IkyJL#yKAPlEUiK5#(- zNB{{S0VIF~kN^@u0!RP}AOR$R1pXHUCKB_pK4FEpn8>E1fdV%EpGlRidl#S{Y3B%*mV6GQC|VaExc!TsC3ep{cItgYT2(41}V8 zWNTw|I5VC-+_(a-nz8t}2k~y@bo7SBQo2uCvhA8HRc%|F>^j|a%S4zj>Dm?;;!?XV zgIT%>W}Rvo=+B6hH_>;!vbmnOG@YX_L5VdVXxuLqNQJYv|#HNaEQHa>XY3y2= zR&~1S-N?Zl!hFScP3n*uYq++i8nwI*IUP$i@_t;uOi$%z+_D9x>8?T?$9Ck|71eOb zv}~!&R+*ifS0|vSVseFqaQVP-bvrGyom4!S6B}S_Dcb}FBzE<`9bmK5I6Iihg4|E?x>~8h*qvmmx>bh170;^ho#Q8@jm^H7G&@HR z{n?@Z@;?C6A8fso2op^{1bF-F#Nvu5<@zV~!_2Zq0t{1sDc`2sVf zQ~3bcJ_yf7QSUdL1iR?(9Eu9h$6@!RJak{S|k6vR*fA z2e#n=Ro$A>Ji?k44DkAn-s>K~PuIg^d{g?~1^AtIOO5j$W3J0(+Qy#yxXdD$8{X(& zsjmo4ZgUonhEMh9)Y{kpIll~htmuLoya&M34R|h);Pr@(`)rRMCFHsfjefwjewD;$mdtf3T|IdgEg7~|*Eq*J0DlUjWi-!1} z_@($<#P{@H>!*Hf91=hRNB{{S0VIF~kN^@u0!RP}yaIud!~_h$2O}t%1Br3i^G7j; z#l%tA;eluts!zX>m}(oVKQnYNkqvY3=;mN&mYGzf5*8B~=*)3C8L5N^G!mUz3h{Je zEbW`c#{XmD_X7Omf&`EN5