From 215dfd66fd13229aba803a9f0d0e0c553f2296be Mon Sep 17 00:00:00 2001 From: Lars Artmann Date: Wed, 9 Sep 2026 04:39:11 +0200 Subject: [PATCH 1/2] 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/2] 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) +}