diff --git a/CHANGELOG.md b/CHANGELOG.md index f681387..9478a72 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,10 +17,14 @@ Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html) never error, and session files stay untouched. - **Mutation evidence carries the command it ran.** `ToolCallEvidence` gains a bounded one-line `summary` for command-carrying tools (Bash and the provider dialects normalized onto the `command` input). Paths answered "did this step touch the workspace" while hiding *how* — auditing a Bash mutation meant opening the raw JSONL. (#21) - **`ccx trace --width N` controls outline headline truncation** (0 = untruncated; default stays 160), applied to text and JSON outlines alike. Previously a constant, and the only escape was `--full`'s entire bundle — which bit JSON skill/script consumers hardest. (#23) +- **`ccx search --content` scans transcript lines.** Search covered names, paths, and summaries only, so the main session-mining question — "what did we discuss about X" — fell back to raw `grep -r` over the store, losing session identity, provider abstraction, and date filters. `--content` streams every candidate session file plus its subagent files (grep parity by design: raw-line match with unbounded line reads, no parse, so every provider's format works and nothing grep finds is missed — including matches past lines too large for any fixed scanner budget), ranks results by hit count, and composes with `--after`/`--before`/`-p`/`--model`. Truncated result lists say so on stderr. It is a crawl (~15s over a multi-GB store) because no message index exists yet; a content index is the follow-up that would make it a query. ### Fixed - **Git-root fallback is provenance, not a warning.** `session_git_root_missing` fired whenever the session's recorded cwd didn't resolve locally, even when the process-cwd fallback then found the right repo — the common case in containers, where every trace carried the warning despite correct correlation. A successful fallback now records `git.resolved_from` (`"session_cwd"` | `"process_cwd"`); the warning fires only when nothing resolves. (#22) - **Codex cost no longer double-bills cached input and reasoning tokens.** Codex usage fields are subsets, not disjoint categories: `input_tokens` includes `cached_input_tokens` (upstream: `non_cached_input = input - cached`) and `output_tokens` includes reasoning (OpenAI `output_tokens_details`) — ccx billed every field separately, overstating a real 36-minute session 2.6x ($101.99 shown, $38.54 honest) and printing "6.6m in" for ~206k of uncached input while Claude's `in` excludes cache. The Codex backend now normalizes to the exclusive semantics the rest of ccx assumes; `ComputeCost` drops the reasoning term; cache format bumped so upgrades reparse. Found by the first cross-provider field eval. (#27) +- **Trace sidechain reports are whole in JSON evidence.** `--full` and `--turn` capped `sidechains[].summary` at 240 runes — for research sessions the subagent final report IS the value, so the "complete trace bundle" was incomplete exactly where it mattered. The top-level sidechains list now carries the untruncated (ANSI-stripped) report; step-level entries stay bounded light refs keyed by `agent_id`, as documented. +- **`ccx view` respects pipes: `--color=auto|always|never`.** ANSI codes were emitted unconditionally, so piped output read as binary to grep — silent false-negatives unless you knew to add `-a`. `auto` (the default) follows whether stdout is a terminal; detection is stdlib-only (`os.ModeCharDevice`), keeping the zero-dependency stance. Session content is also scrubbed: escapes and control bytes embedded in tool results (untrusted terminal input) no longer reach the terminal or pipes in any color mode. +- **`ccx view` no longer indents sequential messages ever-deeper.** Every message nested one level under its `parentUuid` predecessor, so a long linear conversation drifted right without bound (400+ columns by the end of an 8.5k-line session). Sequential messages are siblings; indentation now marks only real branch descent — main chain into sidechain, or one agent into another. ### Changed - **Web UI wears terminal material now** — cctrace's design language ported onto ccx's markup (every selector and JS hook kept, values rewritten). 13px `ui-monospace` body replaces 17px system sans ('Courier New' led the old mono stack); warm-tinted neutrals with terracotta as the single accent plus a five-hue semantic set (green/red/amber/purple/blue) replace ~12 stray hues; pastel role bubbles become hairline surfaces with faint washes — user turns get the cctrace anchor mechanic (space above + accent-washed header row); thinking is muted italic. Chrome details: thin scrollbars, accent selection, visible focus, tinted shadows, one radius scale. Second side-stripe purge caught what 0.11 missed (tool blocks, outline active item, agent turns, doctor/memory cards). Devlog: `docs/devlog/2026-07-29-web-terminal-material.org`. diff --git a/docs/devlog/2026-08-02-dogfood-friction-fixes.org b/docs/devlog/2026-08-02-dogfood-friction-fixes.org new file mode 100644 index 0000000..5d670f2 --- /dev/null +++ b/docs/devlog/2026-08-02-dogfood-friction-fixes.org @@ -0,0 +1,68 @@ +* [2026-08-02] Dev Log: Dogfood friction fixes :TRACE:VIEW:SEARCH: + +** Context +A topic-mining session used ccx as its primary instrument against the +real session store (fixture: a 12-hour research session — 3 sidechains, +8.5k-line view) and filed four friction findings. All four were +reproducible; all four are fixed here. + +** Why +Each flaw broke the same workflow ccx exists for — getting evidence +back out of session history: +1. =trace --full= capped sidechain summaries at 240 runes; for research + sessions the subagent final report IS the value. +2. =view= piped ANSI to non-TTYs; grep saw binary and silently missed. +3. =search= covered names/summaries only; content questions fell back + to raw grep, losing session identity and filters. +4. =view= indented every message under its parentUuid predecessor; + linear sessions drifted 400+ columns right. + +** What +- =FIX= trace: top-level =sidechains[].summary= untruncated + (ANSI-stripped) in =--full=/=--turn=; step refs stay bounded. +- =FEAT= =ccx view --color=auto|always|never=; auto = stdout is a TTY. +- =FIX= view tree: siblings share depth; indent only on branch descent + (main -> sidechain, agent -> other agent). +- =FEAT= =ccx search --content=: streaming raw-line scan over session + + subagent files, ranked by hit count, composing with date/provider + filters. + +** How +- =internal/trace/analysis.go=: =collectSidechainEvidence= keeps the + full final report; =buildTurn= bounds the step-level light ref at + attach time. Full evidence lives once, keyed by agent_id — the shape + the code already claimed. +- =internal/render/terminal.go=: color constants became package vars + behind =setColors(enabled)=; =printMessage= computes child depth from + the =IsSidechain=/=AgentID= boundary instead of unconditional +1. +- =internal/cmd/view.go=: =resolveColorMode= maps the flag; auto uses + =os.ModeCharDevice= (stdlib; no isatty dependency). +- =internal/cmd/search.go=: =contentMatches= scans =.jsonl= plus + =/subagents/*.jsonl= with the 10MB scanner budget. + +** Decisions +| Decision | Alternatives | Rationale | DRI | Timestamp | +| Grep-parity raw-line matching for --content | parse each line, match text fields only | The complaint was false negatives vs grep; raw scan is provider-agnostic, cheap, and misses nothing grep finds. Structure-key false positives are rare for real topic queries. | agent | 2026-08-02 | +| Content scan is a crawl (~15s full store) | build SQLite FTS index first | No message index exists (db = stars/tags only); index lifecycle is its own feature. Crawl lands the workflow now; index is the follow-up. | agent | 2026-08-02 | +| Step-level sidechain refs stay bounded | untruncate everywhere | Light refs exist to keep turns readable; full evidence lives once at top level, keyed by agent_id. | agent | 2026-08-02 | +| Depth = agent-boundary crossings only | cap depth at N; keep +1 per message | Sequential messages are siblings in fact; a cap would hide, not fix, the wrong model. | agent | 2026-08-02 | + +** Notes +- The friction report's "SQLite index presumably makes content search + cheap" was wrong — the db stores stars/tags only. Worth an FTS issue. +- Verification detour: the harness shell aliased =grep= to a function + that swallowed matched output, which briefly looked like a fifth bug + (messages missing from view). =/usr/bin/grep= restored reality: + 67 goose matches in the piped view, no =-a= needed. +- =view= still truncates tool results to 10 lines by design; --content + search is the tool for exhaustive content questions, not view+grep. +- Two-axis review follow-ups applied: content control chars scrubbed in + every color mode (spec asked for the audit; embedded escapes leaked), + =--content= switched to unbounded line reads (10MB scanner budget + silently dropped matches past image-carrying lines), subagent layout + knowledge moved to =parser.SubagentFiles= with =agent-= prefix parity. +- Deferred, known: file-based subagent transcripts parse as extra root + messages, so =view= renders sidechains flat after the main chain + rather than nested under the spawning call. Correct nesting needs a + parser tree change (affects turns/web) — separate issue, not a + release blocker. diff --git a/internal/cmd/search.go b/internal/cmd/search.go index 9ef5d5b..8ee0412 100644 --- a/internal/cmd/search.go +++ b/internal/cmd/search.go @@ -1,8 +1,10 @@ package cmd import ( + "bufio" "encoding/json" "fmt" + "io" "os" "path/filepath" "sort" @@ -21,11 +23,16 @@ var searchCmd = &cobra.Command{ Short: "Search across projects and sessions", Long: `Search for projects and sessions by name or summary. +With --content, also scan transcript lines inside session files +(including subagent files) — grep parity, but with session identity, +provider abstraction, and date filters. + Examples: - ccx search auth # Find sessions about authentication - ccx search myproject # Find project by name - ccx search "fix bug" # Multi-word search - ccx search -t session # Only search sessions`, + ccx search auth # Find sessions about authentication + ccx search myproject # Find project by name + ccx search "fix bug" # Multi-word search + ccx search -t session # Only search sessions + ccx search --content goose # Scan message content (slower)`, Args: cobra.MinimumNArgs(1), RunE: runSearch, } @@ -38,6 +45,7 @@ var ( searchAfter string searchBefore string searchModel string + searchContent bool ) func init() { @@ -48,6 +56,7 @@ func init() { searchCmd.Flags().StringVar(&searchAfter, "after", "", "sessions after date (YYYY-MM-DD)") searchCmd.Flags().StringVar(&searchBefore, "before", "", "sessions before date (YYYY-MM-DD)") searchCmd.Flags().StringVar(&searchModel, "model", "", "filter by model name substring") + searchCmd.Flags().BoolVar(&searchContent, "content", false, "also scan message content in session files (slower)") rootCmd.AddCommand(searchCmd) } @@ -58,6 +67,7 @@ type searchResult struct { Session string `json:"session,omitempty"` Summary string `json:"summary"` Time string `json:"time,omitempty"` + Matches int `json:"matches,omitempty"` Priority int `json:"-"` } @@ -159,6 +169,24 @@ func runSearch(cmd *cobra.Command, args []string) error { Time: formatAge(s.StartTime), Priority: 2, }) + continue + } + + // Content scan: raw transcript lines, main file plus subagent + // files. Grep parity by design — no parse, so it works for + // every provider's format and misses nothing grep would find. + if searchContent { + if n := countContentMatches(s.FilePath, query); n > 0 { + results = append(results, searchResult{ + Type: "content", + Project: projDisplay, + Session: truncateID(s.ID, 8), + Summary: fmt.Sprintf("%d hits · %s", n, sessionSummaryPreview(s.Summary, 48)), + Time: formatAge(s.StartTime), + Matches: n, + Priority: 3, + }) + } } } } @@ -187,13 +215,18 @@ func runSearch(cmd *cobra.Command, args []string) error { } } - // Sort by priority - sort.Slice(results, func(i, j int) bool { - return results[i].Priority < results[j].Priority + // Sort by priority, then by match count within content results. + // Stable so equal-rank results keep discovery order across runs. + sort.SliceStable(results, func(i, j int) bool { + if results[i].Priority != results[j].Priority { + return results[i].Priority < results[j].Priority + } + return results[i].Matches > results[j].Matches }) - // Limit results + // Limit results — never silently. if searchLimit > 0 && len(results) > searchLimit { + fmt.Fprintf(os.Stderr, "showing %d of %d results (raise with -n)\n", searchLimit, len(results)) results = results[:searchLimit] } @@ -270,3 +303,47 @@ func truncateID(id string, max int) string { } return id[:max] } + +// countContentMatches counts transcript lines containing query across +// the main session file and any subagent files beside it (layout +// knowledge lives in parser.SubagentFiles; providers without subagent +// files simply contribute none). +func countContentMatches(sessionPath, query string) int { + if sessionPath == "" { + return 0 + } + count := countMatchingLines(sessionPath, query) + for _, f := range parser.SubagentFiles(sessionPath) { + count += countMatchingLines(f, query) + } + return count +} + +// countMatchingLines streams one JSONL file and counts lines matching +// query case-insensitively. bufio.Reader, not Scanner: transcript +// lines carrying embedded images exceed any fixed budget, and a +// silent early stop is exactly the false-negative class --content +// exists to kill. Unreadable files warn instead of lying "0 hits". +func countMatchingLines(path, query string) int { + file, err := os.Open(path) + if err != nil { + fmt.Fprintf(os.Stderr, "warning: skipping unreadable %s: %v\n", filepath.Base(path), err) + return 0 + } + defer file.Close() + + count := 0 + reader := bufio.NewReaderSize(file, 64*1024) + for { + line, err := reader.ReadString('\n') + if line != "" && strings.Contains(strings.ToLower(line), query) { + count++ + } + if err != nil { + if err != io.EOF { + fmt.Fprintf(os.Stderr, "warning: read error in %s: %v\n", filepath.Base(path), err) + } + return count + } + } +} diff --git a/internal/cmd/search_test.go b/internal/cmd/search_test.go new file mode 100644 index 0000000..3ffaee0 --- /dev/null +++ b/internal/cmd/search_test.go @@ -0,0 +1,80 @@ +package cmd + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestCountMatchingLines(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "s.jsonl") + content := `{"type":"user","text":"tell me about Pi-Agent"} +{"type":"assistant","text":"nothing relevant"} +{"type":"assistant","text":"pi-agent uses ACP"} +` + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + if got := countMatchingLines(path, "pi-agent"); got != 2 { + t.Fatalf("case-insensitive matches: got %d, want 2", got) + } + if got := countMatchingLines(path, "absent-term"); got != 0 { + t.Fatalf("no-match count: got %d, want 0", got) + } + if got := countMatchingLines(filepath.Join(dir, "missing.jsonl"), "x"); got != 0 { + t.Fatalf("missing file must count 0, got %d", got) + } +} + +// countContentMatches must also cover subagent transcripts, which live +// in /subagents/agent-*.jsonl beside the main .jsonl — and +// must count exactly the files view --show-agents renders. +func TestCountContentMatchesIncludesSubagents(t *testing.T) { + dir := t.TempDir() + main := filepath.Join(dir, "abc-123.jsonl") + if err := os.WriteFile(main, []byte(`{"text":"goose in main"}`+"\n"), 0o644); err != nil { + t.Fatal(err) + } + subDir := filepath.Join(dir, "abc-123", "subagents") + if err := os.MkdirAll(subDir, 0o755); err != nil { + t.Fatal(err) + } + sub := `{"text":"goose in sidechain"} +{"text":"more goose here"} +` + if err := os.WriteFile(filepath.Join(subDir, "agent-1.jsonl"), []byte(sub), 0o644); err != nil { + t.Fatal(err) + } + // Non-jsonl files (meta.json) and jsonl without the agent- prefix + // must be skipped, matching what the session parser loads. + if err := os.WriteFile(filepath.Join(subDir, "agent-1.meta.json"), []byte(`{"text":"goose meta"}`), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(subDir, "notes.jsonl"), []byte(`{"text":"goose notes"}`), 0o644); err != nil { + t.Fatal(err) + } + + if got := countContentMatches(main, "goose"); got != 3 { + t.Fatalf("main+subagent matches: got %d, want 3", got) + } + if got := countContentMatches("", "goose"); got != 0 { + t.Fatalf("empty path must count 0, got %d", got) + } +} + +// Grep parity must survive lines larger than any fixed scanner budget +// (transcript lines with embedded images run past 10MB). +func TestCountMatchingLinesOversizedLine(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "big.jsonl") + huge := `{"pad":"` + strings.Repeat("x", 11*1024*1024) + `"}` + content := huge + "\n" + `{"text":"needle after the giant line"}` + "\n" + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + if got := countMatchingLines(path, "needle"); got != 1 { + t.Fatalf("match after oversized line: got %d, want 1", got) + } +} diff --git a/internal/cmd/view.go b/internal/cmd/view.go index adc6867..5b44b35 100644 --- a/internal/cmd/view.go +++ b/internal/cmd/view.go @@ -39,6 +39,7 @@ var ( viewFlat bool viewBrief bool viewAll bool + viewColor string ) func init() { @@ -48,6 +49,23 @@ func init() { viewCmd.Flags().BoolVar(&viewShowAgents, "show-agents", false, "show agent sidechains") viewCmd.Flags().BoolVar(&viewFlat, "flat", false, "disable tree rendering") viewCmd.Flags().BoolVarP(&viewBrief, "brief", "b", false, "conversation only: human input, agent responses, compactions") + viewCmd.Flags().StringVar(&viewColor, "color", "auto", "colorize output: auto, always, never") +} + +// resolveColorMode maps --color to a concrete decision; auto follows +// whether stdout is a terminal, so piped output stays grep-clean. +func resolveColorMode(mode string) (bool, error) { + switch mode { + case "auto": + info, err := os.Stdout.Stat() + return err == nil && info.Mode()&os.ModeCharDevice != 0, nil + case "always": + return true, nil + case "never": + return false, nil + default: + return false, fmt.Errorf("invalid --color %q (valid: auto, always, never)", mode) + } } func runView(cmd *cobra.Command, args []string) error { @@ -87,10 +105,16 @@ func runView(cmd *cobra.Command, args []string) error { fullSession = render.BriefSession(fullSession) } + color, err := resolveColorMode(viewColor) + if err != nil { + return err + } + opts := render.TerminalOptions{ ShowThinking: viewShowThinking, ShowAgents: viewShowAgents, FlatMode: viewFlat, + Color: color, Theme: config.Theme(), } diff --git a/internal/parser/session.go b/internal/parser/session.go index 40e596c..170c20a 100644 --- a/internal/parser/session.go +++ b/internal/parser/session.go @@ -696,29 +696,38 @@ func countToolCalls(content any) int { // files. Returns nil if the subagents directory doesn't exist or is // empty. func loadSidechainFiles(mainSessionPath string) []*Message { + var allMsgs []*Message + for _, filePath := range SubagentFiles(mainSessionPath) { + allMsgs = append(allMsgs, parseSidechainFile(filePath)...) + } + return allMsgs +} + +// SubagentFiles returns the sub-agent transcript files recorded +// alongside a main session file (layout above), in directory order. +// Nil when the session has none. This is the one place that knows the +// on-disk sidechain layout; consumers (session parsing, content +// search) go through it. +func SubagentFiles(mainSessionPath string) []string { sessionID := extractSessionID(mainSessionPath) if sessionID == "" { return nil } - dir := filepath.Dir(mainSessionPath) - subagentsDir := filepath.Join(dir, sessionID, "subagents") - + subagentsDir := filepath.Join(filepath.Dir(mainSessionPath), sessionID, "subagents") entries, err := os.ReadDir(subagentsDir) if err != nil { return nil // directory doesn't exist — no sidechains } - var allMsgs []*Message + var files []string for _, entry := range entries { name := entry.Name() if entry.IsDir() || !strings.HasSuffix(name, ".jsonl") || !strings.HasPrefix(name, "agent-") { continue } - filePath := filepath.Join(subagentsDir, name) - msgs := parseSidechainFile(filePath) - allMsgs = append(allMsgs, msgs...) + files = append(files, filepath.Join(subagentsDir, name)) } - return allMsgs + return files } // parseSidechainFile reads a single agent-*.jsonl and returns its diff --git a/internal/render/terminal.go b/internal/render/terminal.go index f9ef2ff..b580c35 100644 --- a/internal/render/terminal.go +++ b/internal/render/terminal.go @@ -2,31 +2,76 @@ package render import ( "fmt" + "regexp" "strings" "github.com/thevibeworks/ccx/internal/parser" ) +// Session content is untrusted terminal input: tool results can embed +// their own ANSI sequences and control bytes, which retitle windows, +// flip grep into binary mode even under --color=never, and corrupt +// piped output. Strip well-formed escapes first, then any stray +// control chars (keeping \n and \t). +var ( + contentAnsiPattern = regexp.MustCompile(`\x1b(?:\[[0-9;?]*[A-Za-z]|\][^\x07\x1b]*(?:\x07|\x1b\\))`) + contentControlPattern = regexp.MustCompile("[\x00-\x08\x0b-\x1f\x7f]") +) + +func sanitizeContent(s string) string { + return contentControlPattern.ReplaceAllString(contentAnsiPattern.ReplaceAllString(s, ""), "") +} + type TerminalOptions struct { ShowThinking bool ShowAgents bool FlatMode bool + Color bool Theme string } const ( - colorReset = "\033[0m" - colorBold = "\033[1m" - colorDim = "\033[2m" - colorUser = "\033[34m" // Blue - colorAssist = "\033[32m" // Green - colorTool = "\033[35m" // Magenta - colorThink = "\033[33m" // Yellow - colorError = "\033[31m" // Red - colorCompact = "\033[36m" // Cyan + ansiReset = "\033[0m" + ansiBold = "\033[1m" + ansiDim = "\033[2m" + ansiUser = "\033[34m" // Blue + ansiAssist = "\033[32m" // Green + ansiTool = "\033[35m" // Magenta + ansiThink = "\033[33m" // Yellow + ansiError = "\033[31m" // Red + ansiCompact = "\033[36m" // Cyan +) + +// Rendering reads colors through package vars so piped output can drop +// ANSI entirely (escapes make grep treat the output as binary). The CLI +// renders one session per process, so this never races. +var ( + colorReset = ansiReset + colorBold = ansiBold + colorDim = ansiDim + colorUser = ansiUser + colorAssist = ansiAssist + colorTool = ansiTool + colorThink = ansiThink + colorError = ansiError + colorCompact = ansiCompact ) +func setColors(enabled bool) { + if enabled { + colorReset, colorBold, colorDim = ansiReset, ansiBold, ansiDim + colorUser, colorAssist, colorTool = ansiUser, ansiAssist, ansiTool + colorThink, colorError, colorCompact = ansiThink, ansiError, ansiCompact + return + } + colorReset, colorBold, colorDim = "", "", "" + colorUser, colorAssist, colorTool = "", "", "" + colorThink, colorError, colorCompact = "", "", "" +} + func Terminal(session *parser.Session, opts TerminalOptions) error { + setColors(opts.Color) + fmt.Printf("%s%sSession: %s%s\n", colorBold, colorUser, session.ID, colorReset) fmt.Printf("%sStarted: %s | Messages: %d | Tools: %d%s\n\n", colorDim, session.StartTime.Format("2006-01-02 15:04"), @@ -68,7 +113,14 @@ func printMessage(msg *parser.Message, depth int, opts TerminalOptions) { if !opts.FlatMode { for _, child := range msg.Children { - printMessage(child, depth+1, opts) + // Sequential messages are siblings on the parentUuid chain, + // not nesting; indent only when descending into a different + // agent's branch (main -> sidechain, agent -> other agent). + childDepth := depth + if child.IsSidechain != msg.IsSidechain || child.AgentID != msg.AgentID { + childDepth = depth + 1 + } + printMessage(child, childDepth, opts) } } } @@ -77,7 +129,7 @@ func printContentBlock(block parser.ContentBlock, indent string, opts TerminalOp switch block.Type { case "text": if block.Text != "" { - text := wrapText(block.Text, 80-len(indent)) + text := wrapText(sanitizeContent(block.Text), 80-len(indent)) for _, line := range strings.Split(text, "\n") { fmt.Printf("%s%s\n", indent, line) } @@ -86,7 +138,7 @@ func printContentBlock(block parser.ContentBlock, indent string, opts TerminalOp case "thinking": if opts.ShowThinking && block.Text != "" { fmt.Printf("\n%s%s[THINKING]%s\n", indent, colorThink, colorReset) - text := wrapText(block.Text, 80-len(indent)) + text := wrapText(sanitizeContent(block.Text), 80-len(indent)) for _, line := range strings.Split(text, "\n") { fmt.Printf("%s%s%s%s\n", indent, colorDim, line, colorReset) } @@ -121,7 +173,7 @@ func printCompacted(msg *parser.Message, indent string) { fmt.Printf("\n%s%s═══ [COMPACTED] ═══%s\n", indent, colorCompact, colorReset) for _, block := range msg.Content { if block.Type == "text" && block.Text != "" { - summary := block.Text + summary := sanitizeContent(block.Text) if len(summary) > 200 { summary = summary[:197] + "..." } @@ -138,13 +190,14 @@ func printToolInput(input any, indent string) { if key == "content" || key == "input" { continue } - valStr := fmt.Sprintf("%v", val) + valStr := sanitizeContent(fmt.Sprintf("%v", val)) if len(valStr) > 60 { valStr = valStr[:57] + "..." } fmt.Printf("%s%s: %s\n", indent, key, valStr) } case string: + v = sanitizeContent(v) if len(v) > 100 { v = v[:97] + "..." } @@ -155,7 +208,7 @@ func printToolInput(input any, indent string) { func printToolResult(result any, indent string) { switch v := result.(type) { case string: - lines := strings.Split(v, "\n") + lines := strings.Split(sanitizeContent(v), "\n") maxLines := 10 if len(lines) > maxLines { for _, line := range lines[:maxLines] { @@ -174,7 +227,7 @@ func printToolResult(result any, indent string) { } } default: - fmt.Printf("%s%s%v%s\n", indent, colorDim, v, colorReset) + fmt.Printf("%s%s%s%s\n", indent, colorDim, sanitizeContent(fmt.Sprintf("%v", v)), colorReset) } } diff --git a/internal/render/terminal_test.go b/internal/render/terminal_test.go new file mode 100644 index 0000000..7e8312a --- /dev/null +++ b/internal/render/terminal_test.go @@ -0,0 +1,39 @@ +package render + +import ( + "strings" + "testing" +) + +// Embedded escapes in session content must never reach the terminal: +// they retitle windows and flip grep into binary mode even under +// --color=never. +func TestSanitizeContent(t *testing.T) { + cases := []struct { + name, in, want string + }{ + {"sgr", "\x1b[1mbold\x1b[0m plain", "bold plain"}, + {"osc title", "\x1b]0;evil\x07after", "after"}, + {"stray esc and nul", "a\x1bb\x00c", "abc"}, + {"keeps newline and tab", "line1\n\tline2", "line1\n\tline2"}, + {"clean passthrough", "just text", "just text"}, + } + for _, tc := range cases { + if got := sanitizeContent(tc.in); got != tc.want { + t.Errorf("%s: got %q, want %q", tc.name, got, tc.want) + } + } +} + +func TestSanitizeContentNoControlBytesSurvive(t *testing.T) { + in := "x\x01\x02\x7f\x1b[31my\x1b]2;t\x1b\\z" + got := sanitizeContent(in) + for _, r := range got { + if r < 0x20 && r != '\n' && r != '\t' { + t.Fatalf("control byte %q survived in %q", r, got) + } + } + if !strings.Contains(got, "x") || !strings.Contains(got, "y") || !strings.Contains(got, "z") { + t.Fatalf("printable content lost: %q", got) + } +} diff --git a/internal/trace/analysis.go b/internal/trace/analysis.go index 73a6977..17a6bef 100644 --- a/internal/trace/analysis.go +++ b/internal/trace/analysis.go @@ -270,13 +270,14 @@ func buildTurn(index int, anchor *parser.Message, messages []*parser.Message, si summary.Status = msg.SubAgentResult.Status } if summary.Summary == "" { - summary.Summary = summarizeEvidenceText(firstText(msg)) + summary.Summary = firstText(msg) } if msg.SubAgentResult.TotalToolUseCount > summary.ToolCalls { summary.ToolCalls = msg.SubAgentResult.TotalToolUseCount } // Light reference on the step; full evidence lives once in // the top-level sidechains list, keyed by agent_id. + summary.Summary = summarizeEvidenceText(summary.Summary) summary.ToolCallEvidence = nil summary.FilesEdited = nil summary.FilesRead = nil @@ -449,7 +450,10 @@ func collectSidechainEvidence(messages []*parser.Message) map[string]Sidechain { summary.OutputTokens += msg.Usage.OutputTokens summary.CostUSD += msg.Usage.CostUSD } - if text := summarizeEvidenceText(firstText(msg)); text != "" { + // The final report is often the sidechain's whole value (research + // agents); keep it untruncated here, ANSI-stripped only. Step-level + // refs bound it on attach. + if text := stripANSI(firstText(msg)); text != "" { summary.Summary = text } @@ -689,12 +693,15 @@ var ( } ) +func stripANSI(text string) string { + return strings.TrimSpace(ansiEscapePattern.ReplaceAllString(text, "")) +} + // cleanBoundedText normalizes one evidence text field: ANSI escapes // stripped, command XML condensed, and length bounded to maxRunes. // The bool reports whether content was omitted. func cleanBoundedText(text string, maxRunes int) (string, bool) { - text = ansiEscapePattern.ReplaceAllString(text, "") - text = condenseCommandText(text) + text = condenseCommandText(stripANSI(text)) return boundText(text, maxRunes) } diff --git a/internal/trace/analysis_test.go b/internal/trace/analysis_test.go index d840a9c..f68e113 100644 --- a/internal/trace/analysis_test.go +++ b/internal/trace/analysis_test.go @@ -179,6 +179,51 @@ func TestAnalyzeAttachesSidechainEvidence(t *testing.T) { } } +// TestAnalyzeSidechainReportStaysWhole guards the evidence contract for +// research sessions: the subagent's final report is often the whole +// value, so the top-level sidechain entry must carry it untruncated +// (ANSI-stripped), while the step-level light ref stays bounded. +func TestAnalyzeSidechainReportStaysWhole(t *testing.T) { + now := time.Now() + report := "\x1b[1mFindings:\x1b[0m " + strings.Repeat("evidence sentence. ", 200) // ~3800 runes + session := &parser.Session{ + ID: "sidechain-report", + StartTime: now, + EndTime: now.Add(10 * time.Minute), + Stats: parser.SessionStats{AgentSidechains: 1}, + RootMessages: []*parser.Message{ + {UUID: "u1", Kind: parser.KindUserPrompt, Type: "user", Timestamp: now, + Content: []parser.ContentBlock{{Type: "text", Text: "Research the topic"}}}, + {UUID: "a1", Kind: parser.KindAssistant, Type: "assistant", Timestamp: now.Add(time.Minute), + Content: []parser.ContentBlock{ + {Type: "text", Text: "Spawning a researcher."}, + {Type: "tool_use", ToolName: "Agent", ToolID: "tool-1", ToolInput: map[string]any{"subagent_type": "Explore"}}, + }}, + {UUID: "tr1", Kind: parser.KindToolResult, Type: "user", Timestamp: now.Add(2 * time.Minute), + Content: []parser.ContentBlock{{Type: "tool_result", ToolID: "tool-1"}}, + SubAgentResult: &parser.SubAgentResultData{AgentID: "agent-1", AgentType: "Explore", Status: "completed"}}, + {UUID: "sc-a1", Kind: parser.KindAssistant, Type: "assistant", IsSidechain: true, AgentID: "agent-1", Timestamp: now.Add(3 * time.Minute), + Content: []parser.ContentBlock{{Type: "text", Text: report}}}, + }, + } + + result := Analyze(session) + if len(result.Sidechains) != 1 { + t.Fatalf("top-level sidechains: got %d, want 1", len(result.Sidechains)) + } + top := result.Sidechains[0] + if len([]rune(top.Summary)) < 3000 { + t.Fatalf("top-level summary truncated: %d runes", len([]rune(top.Summary))) + } + if strings.Contains(top.Summary, "\x1b") { + t.Fatal("top-level summary must be ANSI-stripped") + } + ref := result.Turns[0].Steps[0].Sidechains[0] + if got := len([]rune(ref.Summary)); got > 250 { + t.Fatalf("step ref summary must stay bounded, got %d runes", got) + } +} + func TestExtractPathsFromPatchAndBashRedirect(t *testing.T) { patch := `*** Begin Patch *** Add File: src/new.go