From 335b5a8c1a0e4ec9248310ae9c23b60dcb7bae1f Mon Sep 17 00:00:00 2001 From: Dennis Zhuang Date: Sun, 8 Feb 2026 00:49:23 +0800 Subject: [PATCH 1/3] feat: reduce AI context token consumption by collapsing successful builds, using tail-biased truncation, and relaxing verbatim output instructions Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 6 +- cmd/devtap/drain.go | 2 + internal/adapter/instructions.go | 6 +- internal/filter/truncate.go | 37 +++- internal/filter/truncate_test.go | 96 ++++++++++ internal/mcp/collapse.go | 88 +++++++++ internal/mcp/collapse_test.go | 244 ++++++++++++++++++++++++ internal/mcp/server.go | 3 +- internal/mcp/truncate.go | 4 +- skills/devtap-get-build-errors/SKILL.md | 11 +- 10 files changed, 477 insertions(+), 20 deletions(-) create mode 100644 internal/mcp/collapse.go create mode 100644 internal/mcp/collapse_test.go diff --git a/CLAUDE.md b/CLAUDE.md index 4b80004..dd0660f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -89,6 +89,8 @@ devtap captures build/dev output from a separate terminal and delivers it here v **Multi-source mode:** when devtap drains from multiple sources, tags are prefixed with "host/label |" (for example, `[devtap: myhost/local | make]`). "host" is the machine name, "label" identifies the source. Show these prefixes as-is. If output includes source warnings (for example, source unreachable), show those warnings verbatim and continue with output from reachable sources. -**Output format:** when "get_build_errors" returns content, present it verbatim in a fenced code block, then add one line: -"Next action: ". +**Output format:** when "get_build_errors" returns content: +If build succeeded, acknowledge briefly (do not repeat the output). +If build failed, present the error output verbatim in a fenced code block. +Then add one line: "Next action: ". diff --git a/cmd/devtap/drain.go b/cmd/devtap/drain.go index a5c8bbd..d4622a9 100644 --- a/cmd/devtap/drain.go +++ b/cmd/devtap/drain.go @@ -110,6 +110,7 @@ func runDrain(cmd *cobra.Command, args []string) error { allMessages = mcp.DedupMessages(allMessages) } + allMessages = mcp.CollapseSuccessful(allMessages) allMessages = mcp.TruncateMessages(allMessages, maxLines) // Handle auto-loop Stop hook (Claude Code specific) @@ -179,6 +180,7 @@ func runDrainSingleSource(cmd *cobra.Command, filterSQL string, maxLines int, ev return fmt.Errorf("drain: %w", err) } + messages = mcp.CollapseSuccessful(messages) messages = mcp.TruncateMessages(messages, maxLines) if event == "Stop" && autoLoop { diff --git a/internal/adapter/instructions.go b/internal/adapter/instructions.go index df0ca5f..7c9507f 100644 --- a/internal/adapter/instructions.go +++ b/internal/adapter/instructions.go @@ -29,8 +29,10 @@ devtap captures build/dev output from a separate terminal and delivers it here v **Multi-source mode:** when devtap drains from multiple sources, tags are prefixed with "host/label |" (for example, "[devtap: myhost/local | make]"). "host" is the machine name, "label" identifies the source. Show these prefixes as-is. If output includes source warnings (for example, source unreachable), show those warnings verbatim and continue with output from reachable sources. -**Output format:** when "get_build_errors" returns content, present it verbatim in a fenced code block, then add one line: -"Next action: ". +**Output format:** when "get_build_errors" returns content: +If build succeeded, acknowledge briefly (do not repeat the output). +If build failed, present the error output verbatim in a fenced code block. +Then add one line: "Next action: ". ` // InstructionBlockLint is the instruction block for lint-based adapters (aider). diff --git a/internal/filter/truncate.go b/internal/filter/truncate.go index 02acf0d..cc58f19 100644 --- a/internal/filter/truncate.go +++ b/internal/filter/truncate.go @@ -2,24 +2,45 @@ package filter import "fmt" -// Truncate applies smart truncation to a list of lines: -// - If lines exceed maxLines, keeps head and tail with an omission notice in between. -// - Merges consecutive duplicate lines into "(repeated N times)" markers. -// maxLines <= 0 means no truncation. +// Truncate applies smart truncation with a 50/50 head/tail split. +// See TruncateWithRatio for details. func Truncate(lines []string, maxLines int) []string { + return TruncateWithRatio(lines, maxLines, 0.5) +} + +// TruncateWithRatio applies smart truncation to a list of lines: +// - Merges consecutive duplicate lines into "(repeated N times)" markers. +// - If lines exceed maxLines, keeps head and tail with an omission notice in between. +// - tailRatio controls what fraction of the budget goes to the tail (0.0–1.0). +// For build output where errors appear at the end, use tailRatio=0.8. +// +// maxLines <= 0 means no truncation. +func TruncateWithRatio(lines []string, maxLines int, tailRatio float64) []string { lines = dedup(lines) if maxLines <= 0 || len(lines) <= maxLines { return lines } - // Keep roughly half at the head and half at the tail - head := maxLines / 2 - tail := maxLines - head - if tail == 0 { + // Budget=1: no room for head + omission marker + tail. Just keep the last line. + if maxLines == 1 { + return lines[len(lines)-1:] + } + + tail := int(float64(maxLines) * tailRatio) + if tail > maxLines { + tail = maxLines + } + head := maxLines - tail + // Ensure at least 1 line on each side when budget allows. + if tail == 0 && maxLines > 1 { tail = 1 head = maxLines - 1 } + if head == 0 && maxLines > 1 { + head = 1 + tail = maxLines - 1 + } omitted := len(lines) - head - tail result := make([]string, 0, head+1+tail) diff --git a/internal/filter/truncate_test.go b/internal/filter/truncate_test.go index 3bbb52b..575d85f 100644 --- a/internal/filter/truncate_test.go +++ b/internal/filter/truncate_test.go @@ -96,3 +96,99 @@ func TestTruncateSingleLine(t *testing.T) { t.Errorf("unexpected result: %v", result) } } + +func TestTruncateWithRatio_TailBiased(t *testing.T) { + // 20 distinct lines, maxLines=10, tailRatio=0.8 → 2 head + 8 tail + lines := make([]string, 20) + for i := range lines { + lines[i] = fmt.Sprintf("line-%d", i) + } + + result := TruncateWithRatio(lines, 10, 0.8) + + // 2 head + 1 omission + 8 tail = 11 entries + if len(result) != 11 { + t.Fatalf("expected 11 lines, got %d: %v", len(result), result) + } + // First 2 should be head + if result[0] != "line-0" || result[1] != "line-1" { + t.Errorf("head: got %q, %q", result[0], result[1]) + } + // Omission marker + if result[2] != "... (10 lines omitted)" { + t.Errorf("omission: got %q", result[2]) + } + // Last 8 should be tail + if result[3] != "line-12" || result[10] != "line-19" { + t.Errorf("tail: got first=%q last=%q", result[3], result[10]) + } +} + +func TestTruncateWithRatio_AllHead(t *testing.T) { + lines := make([]string, 10) + for i := range lines { + lines[i] = fmt.Sprintf("line-%d", i) + } + + // tailRatio=0.0 → still gets at least 1 tail line + result := TruncateWithRatio(lines, 4, 0.0) + // 3 head + 1 omission + 1 tail = 5 + if len(result) != 5 { + t.Fatalf("expected 5 lines, got %d: %v", len(result), result) + } + if result[4] != "line-9" { + t.Errorf("last line should be tail, got %q", result[4]) + } +} + +func TestTruncateWithRatio_AllTail(t *testing.T) { + lines := make([]string, 10) + for i := range lines { + lines[i] = fmt.Sprintf("line-%d", i) + } + + // tailRatio=1.0 → still gets at least 1 head line + result := TruncateWithRatio(lines, 4, 1.0) + // 1 head + 1 omission + 3 tail = 5 + if len(result) != 5 { + t.Fatalf("expected 5 lines, got %d: %v", len(result), result) + } + if result[0] != "line-0" { + t.Errorf("first line should be head, got %q", result[0]) + } + if result[4] != "line-9" { + t.Errorf("last line should be tail, got %q", result[4]) + } +} + +func TestTruncateWithRatio_NoTruncationNeeded(t *testing.T) { + lines := []string{"a", "b", "c"} + result := TruncateWithRatio(lines, 10, 0.8) + if len(result) != 3 { + t.Errorf("expected 3 lines (no truncation), got %d", len(result)) + } +} + +func TestTruncateWithRatio_SingleMax(t *testing.T) { + lines := []string{"a", "b", "c"} + result := TruncateWithRatio(lines, 1, 0.8) + // maxLines=1: return only the last line, no omission marker + if len(result) != 1 { + t.Fatalf("expected 1 line, got %d: %v", len(result), result) + } + if result[0] != "c" { + t.Errorf("expected last line %q, got %q", "c", result[0]) + } +} + +func TestTruncateSingleMax_Legacy(t *testing.T) { + // Truncate (50/50 ratio) with maxLines=1 should also return exactly 1 line. + lines := []string{"x", "y", "z"} + result := Truncate(lines, 1) + if len(result) != 1 { + t.Fatalf("expected 1 line, got %d: %v", len(result), result) + } + if result[0] != "z" { + t.Errorf("expected last line %q, got %q", "z", result[0]) + } +} diff --git a/internal/mcp/collapse.go b/internal/mcp/collapse.go new file mode 100644 index 0000000..a0b67f8 --- /dev/null +++ b/internal/mcp/collapse.go @@ -0,0 +1,88 @@ +package mcp + +import ( + "fmt" + + "github.com/killme2008/devtap/internal/store" +) + +// CollapseSuccessful replaces the output of successful build runs (exit code 0) +// with a single-line summary to reduce context token consumption. +// +// A "run" is a sequence of messages sharing the same tag, bounded by an exit +// code message. When the same tag appears in multiple runs within a single +// drain window (e.g. a failed build followed by a successful rebuild), each +// run is evaluated independently — only runs with exit code 0 are collapsed. +// Runs with non-zero exit code or no exit code are returned unchanged. +func CollapseSuccessful(messages []store.LogMessage) []store.LogMessage { + type run struct { + firstIdx int + indices []int + exitCode *int + lines int + } + + // Work on a copy so we can replace run heads in-place while preserving the + // original global message order. + result := make([]store.LogMessage, len(messages)) + copy(result, messages) + removed := make([]bool, len(result)) + + // current tracks the active (not yet finalized) run per tag. + current := make(map[string]*run) + + for i, msg := range result { + tag := msg.Tag + if tag == "" { + tag = "build" + } + + r, exists := current[tag] + if !exists { + r = &run{firstIdx: i} + current[tag] = r + } + + r.indices = append(r.indices, i) + r.lines += len(msg.Lines) + if msg.ExitCode != nil { + r.exitCode = msg.ExitCode + if *r.exitCode == 0 && r.lines > 0 { + // Collapse successful run into a single summary message placed at the + // first message position to preserve global ordering. + first := result[r.firstIdx] + result[r.firstIdx] = store.LogMessage{ + Timestamp: first.Timestamp, + Tag: first.Tag, + Stream: first.Stream, + ExitCode: r.exitCode, + Adapter: first.Adapter, + Host: first.Host, + Lines: []string{collapseMessage(r.lines)}, + } + for _, idx := range r.indices[1:] { + removed[idx] = true + } + } + // Finalize this run; next message for the same tag starts a new run. + delete(current, tag) + } + } + + final := make([]store.LogMessage, 0, len(result)) + for i, msg := range result { + if !removed[i] { + final = append(final, msg) + } + } + + return final +} + +func collapseMessage(lineCount int) string { + noun := "lines" + if lineCount == 1 { + noun = "line" + } + return fmt.Sprintf("(%d %s of output omitted — build succeeded)", lineCount, noun) +} diff --git a/internal/mcp/collapse_test.go b/internal/mcp/collapse_test.go new file mode 100644 index 0000000..210229d --- /dev/null +++ b/internal/mcp/collapse_test.go @@ -0,0 +1,244 @@ +package mcp + +import ( + "testing" + "time" + + "github.com/killme2008/devtap/internal/store" +) + +func intPtr(v int) *int { return &v } + +func TestCollapseSuccessful_ExitZero(t *testing.T) { + ts := time.Now() + messages := []store.LogMessage{ + {Timestamp: ts, Tag: "go-build", Stream: "stdout", Lines: []string{"line1", "line2", "line3"}}, + {Timestamp: ts, Tag: "go-build", Stream: "exit", ExitCode: intPtr(0)}, + } + + result := CollapseSuccessful(messages) + + if len(result) != 1 { + t.Fatalf("expected 1 message, got %d", len(result)) + } + if len(result[0].Lines) != 1 { + t.Fatalf("expected 1 line, got %d", len(result[0].Lines)) + } + want := "(3 lines of output omitted — build succeeded)" + if result[0].Lines[0] != want { + t.Errorf("got %q, want %q", result[0].Lines[0], want) + } + if result[0].Tag != "go-build" { + t.Errorf("tag: got %q, want %q", result[0].Tag, "go-build") + } + if result[0].ExitCode == nil || *result[0].ExitCode != 0 { + t.Error("exit code should be preserved as 0") + } +} + +func TestCollapseSuccessful_NonZeroPreserved(t *testing.T) { + ts := time.Now() + messages := []store.LogMessage{ + {Timestamp: ts, Tag: "cargo", Stream: "stderr", Lines: []string{"error[E0308]: mismatched types"}}, + {Timestamp: ts, Tag: "cargo", Stream: "exit", ExitCode: intPtr(1)}, + } + + result := CollapseSuccessful(messages) + + if len(result) != 2 { + t.Fatalf("expected 2 messages (unchanged), got %d", len(result)) + } + if result[0].Lines[0] != "error[E0308]: mismatched types" { + t.Error("error message should be preserved") + } +} + +func TestCollapseSuccessful_NoExitCodePreserved(t *testing.T) { + ts := time.Now() + messages := []store.LogMessage{ + {Timestamp: ts, Tag: "dev-server", Stream: "stdout", Lines: []string{"listening on :8080"}}, + } + + result := CollapseSuccessful(messages) + + if len(result) != 1 { + t.Fatalf("expected 1 message, got %d", len(result)) + } + if result[0].Lines[0] != "listening on :8080" { + t.Error("message without exit code should be preserved") + } +} + +func TestCollapseSuccessful_MixedTags(t *testing.T) { + ts := time.Now() + messages := []store.LogMessage{ + // Successful build + {Timestamp: ts, Tag: "go-build", Stream: "stdout", Lines: []string{"compiling...", "done"}}, + {Timestamp: ts, Tag: "go-build", Stream: "exit", ExitCode: intPtr(0)}, + // Failed test + {Timestamp: ts, Tag: "go-test", Stream: "stderr", Lines: []string{"FAIL TestFoo"}}, + {Timestamp: ts, Tag: "go-test", Stream: "exit", ExitCode: intPtr(1)}, + } + + result := CollapseSuccessful(messages) + + // go-build collapsed to 1, go-test preserved as 2 + if len(result) != 3 { + t.Fatalf("expected 3 messages, got %d", len(result)) + } + + // First should be collapsed summary + if len(result[0].Lines) != 1 || result[0].Tag != "go-build" { + t.Errorf("first message should be collapsed go-build, got tag=%q lines=%v", result[0].Tag, result[0].Lines) + } + + // Remaining should be go-test preserved + if result[1].Lines[0] != "FAIL TestFoo" { + t.Error("go-test error should be preserved") + } +} + +func TestCollapseSuccessful_EmptyMessages(t *testing.T) { + result := CollapseSuccessful(nil) + if len(result) != 0 { + t.Errorf("expected 0 messages, got %d", len(result)) + } +} + +func TestCollapseSuccessful_ExitZeroNoLines(t *testing.T) { + // Exit code 0 but zero output lines — nothing to collapse + ts := time.Now() + messages := []store.LogMessage{ + {Timestamp: ts, Tag: "build", Stream: "exit", ExitCode: intPtr(0)}, + } + + result := CollapseSuccessful(messages) + + // No lines to collapse, so the exit message is kept as-is + if len(result) != 1 { + t.Fatalf("expected 1 message, got %d", len(result)) + } + if len(result[0].Lines) != 0 { + t.Errorf("expected 0 lines, got %d", len(result[0].Lines)) + } +} + +func TestCollapseSuccessful_SameTagFailThenSuccess(t *testing.T) { + // Same tag appears twice: first run fails, second run succeeds. + // The failure must be preserved; only the success run is collapsed. + ts := time.Now() + messages := []store.LogMessage{ + // Run 1: failure + {Timestamp: ts, Tag: "make", Stream: "stderr", Lines: []string{"error: undefined reference"}}, + {Timestamp: ts, Tag: "make", Stream: "exit", ExitCode: intPtr(2)}, + // Run 2: success after fix + {Timestamp: ts, Tag: "make", Stream: "stdout", Lines: []string{"compiling...", "linking...", "done"}}, + {Timestamp: ts, Tag: "make", Stream: "exit", ExitCode: intPtr(0)}, + } + + result := CollapseSuccessful(messages) + + // Run 1 preserved (2 messages), run 2 collapsed (1 summary) + if len(result) != 3 { + t.Fatalf("expected 3 messages, got %d: %+v", len(result), result) + } + + // First two: the failed run, unchanged + if result[0].Lines[0] != "error: undefined reference" { + t.Errorf("failed run output should be preserved, got %q", result[0].Lines[0]) + } + if result[1].ExitCode == nil || *result[1].ExitCode != 2 { + t.Errorf("failed run exit code should be 2, got %v", result[1].ExitCode) + } + + // Third: collapsed success summary + want := "(3 lines of output omitted — build succeeded)" + if len(result[2].Lines) != 1 || result[2].Lines[0] != want { + t.Errorf("success run should be collapsed, got %v", result[2].Lines) + } +} + +func TestCollapseSuccessful_SameTagSuccessThenFail(t *testing.T) { + // Reverse order: success first, then failure. Failure must be preserved. + ts := time.Now() + messages := []store.LogMessage{ + // Run 1: success + {Timestamp: ts, Tag: "build", Stream: "stdout", Lines: []string{"ok"}}, + {Timestamp: ts, Tag: "build", Stream: "exit", ExitCode: intPtr(0)}, + // Run 2: failure + {Timestamp: ts, Tag: "build", Stream: "stderr", Lines: []string{"FAIL"}}, + {Timestamp: ts, Tag: "build", Stream: "exit", ExitCode: intPtr(1)}, + } + + result := CollapseSuccessful(messages) + + // Run 1 collapsed (1 summary), run 2 preserved (2 messages) + if len(result) != 3 { + t.Fatalf("expected 3 messages, got %d: %+v", len(result), result) + } + + // First: collapsed success + if len(result[0].Lines) != 1 { + t.Fatalf("success run should be 1 summary line, got %d", len(result[0].Lines)) + } + + // Last two: preserved failure + if result[1].Lines[0] != "FAIL" { + t.Errorf("failure output should be preserved, got %q", result[1].Lines[0]) + } + if result[2].ExitCode == nil || *result[2].ExitCode != 1 { + t.Error("failure exit code should be preserved") + } +} + +func TestCollapseSuccessful_InterleavedTags(t *testing.T) { + // Two tags interleaved: make fails, test succeeds. + // Output order should remain aligned with original global message ordering. + ts := time.Now() + messages := []store.LogMessage{ + {Timestamp: ts, Tag: "make", Stream: "stderr", Lines: []string{"err1"}}, + {Timestamp: ts, Tag: "test", Stream: "stdout", Lines: []string{"ok1"}}, + {Timestamp: ts, Tag: "make", Stream: "exit", ExitCode: intPtr(1)}, + {Timestamp: ts, Tag: "test", Stream: "exit", ExitCode: intPtr(0)}, + } + + result := CollapseSuccessful(messages) + + // make: 2 messages preserved, test: 1 collapsed summary + if len(result) != 3 { + t.Fatalf("expected 3 messages, got %d: %+v", len(result), result) + } + // First: make output preserved. + if result[0].Lines[0] != "err1" { + t.Errorf("make error should be preserved, got %q", result[0].Lines[0]) + } + // Second: test summary replaces the first test line position. + if result[1].ExitCode == nil || *result[1].ExitCode != 0 { + t.Error("test run should be collapsed with exit code 0") + } + if len(result[1].Lines) != 1 { + t.Errorf("collapsed test should have 1 summary line, got %d", len(result[1].Lines)) + } + // Third: make exit preserved at its original relative position. + if result[2].ExitCode == nil || *result[2].ExitCode != 1 { + t.Error("make exit code 1 should be preserved") + } +} + +func TestCollapseSuccessful_EmptyTagDefaultsBuild(t *testing.T) { + ts := time.Now() + messages := []store.LogMessage{ + {Timestamp: ts, Tag: "", Stream: "stdout", Lines: []string{"output"}}, + {Timestamp: ts, Tag: "", Stream: "exit", ExitCode: intPtr(0)}, + } + + result := CollapseSuccessful(messages) + + if len(result) != 1 { + t.Fatalf("expected 1 message (collapsed), got %d", len(result)) + } + want := "(1 line of output omitted — build succeeded)" + if result[0].Lines[0] != want { + t.Errorf("got %q, want %q", result[0].Lines[0], want) + } +} diff --git a/internal/mcp/server.go b/internal/mcp/server.go index 39f26ef..fb4d972 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -135,7 +135,7 @@ func (s *Server) toolDefinitions() []Tool { return []Tool{ { Name: "get_build_errors", - Description: "Get pending build errors and output captured by devtap. Call this at the start of each task and before writing or editing code to check for build failures that need fixing. A separate terminal may have captured new build errors or user messages at any time. Always present the full captured output to the user verbatim — do not summarize, omit, or reinterpret any content, even if the build succeeded.", + Description: "Get pending build errors and output captured by devtap. Call this at the start of each task and before writing or editing code to check for build failures that need fixing. A separate terminal may have captured new build errors or user messages at any time. If build succeeded, acknowledge briefly without repeating the output. If build failed, present the error output verbatim — do not summarize or reinterpret error content.", InputSchema: InputSchema{ Type: "object", Properties: map[string]Property{}, @@ -233,6 +233,7 @@ func (s *Server) handleGetBuildErrors(id any) { return } + allMessages = CollapseSuccessful(allMessages) allMessages = TruncateMessages(allMessages, s.maxLines) text := FormatMessages(allMessages) diff --git a/internal/mcp/truncate.go b/internal/mcp/truncate.go index 4f0339f..fb1246c 100644 --- a/internal/mcp/truncate.go +++ b/internal/mcp/truncate.go @@ -7,7 +7,7 @@ import ( // TruncateMessages applies line-level truncation across messages. // It allocates the maxLines budget proportionally to each message, -// using filter.Truncate for smart head/tail truncation per message. +// using filter.TruncateWithRatio for tail-biased (80/20) truncation per message. func TruncateMessages(messages []store.LogMessage, maxLines int) []store.LogMessage { if maxLines <= 0 { return messages @@ -39,7 +39,7 @@ func TruncateMessages(messages []store.LogMessage, maxLines int) []store.LogMess if share > remaining { share = remaining } - result[i].Lines = filter.Truncate(result[i].Lines, share) + result[i].Lines = filter.TruncateWithRatio(result[i].Lines, share, 0.8) remaining -= len(result[i].Lines) if remaining <= 0 { // Clear remaining messages' lines. diff --git a/skills/devtap-get-build-errors/SKILL.md b/skills/devtap-get-build-errors/SKILL.md index 741b249..4481dab 100644 --- a/skills/devtap-get-build-errors/SKILL.md +++ b/skills/devtap-get-build-errors/SKILL.md @@ -1,6 +1,6 @@ --- name: devtap-get-build-errors -description: Fetch pending devtap build/dev output for the current turn using get_build_status and get_build_errors, then present the captured output verbatim. Use when the user asks to check build errors, latest build logs, or "/get_build_errors"-style actions. +description: Fetch pending devtap build/dev output for the current turn using get_build_status and get_build_errors. Use when the user asks to check build errors, latest build logs, or "/get_build_errors"-style actions. metadata: short-description: Fetch devtap output --- @@ -16,13 +16,14 @@ Use this skill when the user asks for build errors, latest build output, or an e - status reports pending output - user explicitly asks to fetch/check logs now - user says new build/test/dev output arrived -3. Present `get_build_errors` content verbatim in a fenced code block. -4. Keep source warnings verbatim (for example, unreachable source warnings). -5. After the block, add one line: `Next action: `. +3. If build succeeded, acknowledge briefly (do not repeat the output). +4. If build failed, present the error output verbatim in a fenced code block. +5. Keep source warnings verbatim (for example, unreachable source warnings). +6. After the output, add one line: `Next action: `. ## Rules -- Do not summarize or rewrite build output. +- Do not fabricate or reinterpret error content. - Do not call `get_build_errors` repeatedly in the same turn unless new output is reported. - MCP tool names map to CLI subcommands: `get_build_status` → `devtap status`, `get_build_errors` → `devtap drain`. - If MCP tools are unavailable, use `scripts/get_build_errors.sh` as CLI fallback. From c5b7de8e121dfa85e25eef4c922df463b3210890 Mon Sep 17 00:00:00 2001 From: Dennis Zhuang Date: Sun, 8 Feb 2026 01:02:29 +0800 Subject: [PATCH 2/3] feat: simplify truncate ratio Signed-off-by: Dennis Zhuang --- internal/filter/truncate.go | 24 ++--- internal/filter/truncate_test.go | 157 +++++++++---------------------- internal/mcp/truncate.go | 4 +- 3 files changed, 58 insertions(+), 127 deletions(-) diff --git a/internal/filter/truncate.go b/internal/filter/truncate.go index cc58f19..c1a1ad8 100644 --- a/internal/filter/truncate.go +++ b/internal/filter/truncate.go @@ -2,20 +2,17 @@ package filter import "fmt" -// Truncate applies smart truncation with a 50/50 head/tail split. -// See TruncateWithRatio for details. -func Truncate(lines []string, maxLines int) []string { - return TruncateWithRatio(lines, maxLines, 0.5) -} +// tailRatio is the fraction of the line budget allocated to the tail. +// Build errors typically appear at the end, so we keep 80% tail / 20% head. +const tailRatio = 0.8 -// TruncateWithRatio applies smart truncation to a list of lines: +// Truncate applies smart truncation to a list of lines: // - Merges consecutive duplicate lines into "(repeated N times)" markers. // - If lines exceed maxLines, keeps head and tail with an omission notice in between. -// - tailRatio controls what fraction of the budget goes to the tail (0.0–1.0). -// For build output where errors appear at the end, use tailRatio=0.8. +// - Tail-biased: 80% of the budget goes to the tail where errors typically appear. // // maxLines <= 0 means no truncation. -func TruncateWithRatio(lines []string, maxLines int, tailRatio float64) []string { +func Truncate(lines []string, maxLines int) []string { lines = dedup(lines) if maxLines <= 0 || len(lines) <= maxLines { @@ -28,16 +25,13 @@ func TruncateWithRatio(lines []string, maxLines int, tailRatio float64) []string } tail := int(float64(maxLines) * tailRatio) - if tail > maxLines { - tail = maxLines - } head := maxLines - tail - // Ensure at least 1 line on each side when budget allows. - if tail == 0 && maxLines > 1 { + // Ensure at least 1 line on each side. + if tail == 0 { tail = 1 head = maxLines - 1 } - if head == 0 && maxLines > 1 { + if head == 0 { head = 1 tail = maxLines - 1 } diff --git a/internal/filter/truncate_test.go b/internal/filter/truncate_test.go index 575d85f..0c6d432 100644 --- a/internal/filter/truncate_test.go +++ b/internal/filter/truncate_test.go @@ -35,143 +35,62 @@ func TestTruncateWithDuplicates(t *testing.T) { } } -func TestTruncateDistinctLines(t *testing.T) { - // 20 distinct lines should be truncated to head + omission + tail +func TestTruncateTailBiased(t *testing.T) { + // 20 distinct lines, maxLines=10 → with 0.8 tail ratio: 2 head + 8 tail lines := make([]string, 20) for i := range lines { lines[i] = fmt.Sprintf("line-%d", i) } - result := Truncate(lines, 6) - - // Should have 3 head + 1 omission + 3 tail = 7 - found := false - for _, line := range result { - if line == "... (14 lines omitted)" { - found = true - break - } - } - if !found { - t.Errorf("expected omission marker in result: %v", result) - } - if len(result) > 7 { - t.Errorf("expected <= 7 lines, got %d", len(result)) - } -} - -func TestDedupConsecutive(t *testing.T) { - lines := []string{"a", "a", "a", "b", "b", "c"} - result := dedup(lines) - - expected := []string{"a", "(repeated 3 times)", "b", "(repeated 2 times)", "c"} - if len(result) != len(expected) { - t.Fatalf("expected %d lines, got %d: %v", len(expected), len(result), result) - } - for i, want := range expected { - if result[i] != want { - t.Errorf("line %d: got %q, want %q", i, result[i], want) - } - } -} - -func TestDedupNoRepeats(t *testing.T) { - lines := []string{"a", "b", "c"} - result := dedup(lines) - if len(result) != 3 { - t.Errorf("expected 3 lines, got %d", len(result)) - } -} - -func TestDedupEmpty(t *testing.T) { - result := dedup(nil) - if len(result) != 0 { - t.Errorf("expected 0 lines, got %d", len(result)) - } -} - -func TestTruncateSingleLine(t *testing.T) { - result := Truncate([]string{"only"}, 1) - if len(result) != 1 || result[0] != "only" { - t.Errorf("unexpected result: %v", result) - } -} - -func TestTruncateWithRatio_TailBiased(t *testing.T) { - // 20 distinct lines, maxLines=10, tailRatio=0.8 → 2 head + 8 tail - lines := make([]string, 20) - for i := range lines { - lines[i] = fmt.Sprintf("line-%d", i) - } - - result := TruncateWithRatio(lines, 10, 0.8) + result := Truncate(lines, 10) // 2 head + 1 omission + 8 tail = 11 entries if len(result) != 11 { t.Fatalf("expected 11 lines, got %d: %v", len(result), result) } - // First 2 should be head if result[0] != "line-0" || result[1] != "line-1" { t.Errorf("head: got %q, %q", result[0], result[1]) } - // Omission marker if result[2] != "... (10 lines omitted)" { t.Errorf("omission: got %q", result[2]) } - // Last 8 should be tail if result[3] != "line-12" || result[10] != "line-19" { t.Errorf("tail: got first=%q last=%q", result[3], result[10]) } } -func TestTruncateWithRatio_AllHead(t *testing.T) { - lines := make([]string, 10) +func TestTruncateDistinctLines(t *testing.T) { + // 20 distinct lines, maxLines=6 → with 0.8 tail ratio: 1 head + 4 tail (int(6*0.8)=4) + lines := make([]string, 20) for i := range lines { lines[i] = fmt.Sprintf("line-%d", i) } - // tailRatio=0.0 → still gets at least 1 tail line - result := TruncateWithRatio(lines, 4, 0.0) - // 3 head + 1 omission + 1 tail = 5 - if len(result) != 5 { - t.Fatalf("expected 5 lines, got %d: %v", len(result), result) - } - if result[4] != "line-9" { - t.Errorf("last line should be tail, got %q", result[4]) - } -} - -func TestTruncateWithRatio_AllTail(t *testing.T) { - lines := make([]string, 10) - for i := range lines { - lines[i] = fmt.Sprintf("line-%d", i) - } + result := Truncate(lines, 6) - // tailRatio=1.0 → still gets at least 1 head line - result := TruncateWithRatio(lines, 4, 1.0) - // 1 head + 1 omission + 3 tail = 5 - if len(result) != 5 { - t.Fatalf("expected 5 lines, got %d: %v", len(result), result) + // head=2 (6-4), tail=4 → 2 head + 1 omission + 4 tail = 7 + if len(result) != 7 { + t.Fatalf("expected 7 lines, got %d: %v", len(result), result) } - if result[0] != "line-0" { - t.Errorf("first line should be head, got %q", result[0]) + if result[2] != "... (14 lines omitted)" { + t.Errorf("expected omission marker, got %q", result[2]) } - if result[4] != "line-9" { - t.Errorf("last line should be tail, got %q", result[4]) + // Tail should end with the last line + if result[6] != "line-19" { + t.Errorf("last line should be line-19, got %q", result[6]) } } -func TestTruncateWithRatio_NoTruncationNeeded(t *testing.T) { - lines := []string{"a", "b", "c"} - result := TruncateWithRatio(lines, 10, 0.8) - if len(result) != 3 { - t.Errorf("expected 3 lines (no truncation), got %d", len(result)) +func TestTruncateSingleLine(t *testing.T) { + result := Truncate([]string{"only"}, 1) + if len(result) != 1 || result[0] != "only" { + t.Errorf("unexpected result: %v", result) } } -func TestTruncateWithRatio_SingleMax(t *testing.T) { +func TestTruncateSingleMax(t *testing.T) { lines := []string{"a", "b", "c"} - result := TruncateWithRatio(lines, 1, 0.8) + result := Truncate(lines, 1) // maxLines=1: return only the last line, no omission marker if len(result) != 1 { t.Fatalf("expected 1 line, got %d: %v", len(result), result) @@ -181,14 +100,32 @@ func TestTruncateWithRatio_SingleMax(t *testing.T) { } } -func TestTruncateSingleMax_Legacy(t *testing.T) { - // Truncate (50/50 ratio) with maxLines=1 should also return exactly 1 line. - lines := []string{"x", "y", "z"} - result := Truncate(lines, 1) - if len(result) != 1 { - t.Fatalf("expected 1 line, got %d: %v", len(result), result) +func TestDedupConsecutive(t *testing.T) { + lines := []string{"a", "a", "a", "b", "b", "c"} + result := dedup(lines) + + expected := []string{"a", "(repeated 3 times)", "b", "(repeated 2 times)", "c"} + if len(result) != len(expected) { + t.Fatalf("expected %d lines, got %d: %v", len(expected), len(result), result) + } + for i, want := range expected { + if result[i] != want { + t.Errorf("line %d: got %q, want %q", i, result[i], want) + } + } +} + +func TestDedupNoRepeats(t *testing.T) { + lines := []string{"a", "b", "c"} + result := dedup(lines) + if len(result) != 3 { + t.Errorf("expected 3 lines, got %d", len(result)) } - if result[0] != "z" { - t.Errorf("expected last line %q, got %q", "z", result[0]) +} + +func TestDedupEmpty(t *testing.T) { + result := dedup(nil) + if len(result) != 0 { + t.Errorf("expected 0 lines, got %d", len(result)) } } diff --git a/internal/mcp/truncate.go b/internal/mcp/truncate.go index fb1246c..6c5fc76 100644 --- a/internal/mcp/truncate.go +++ b/internal/mcp/truncate.go @@ -7,7 +7,7 @@ import ( // TruncateMessages applies line-level truncation across messages. // It allocates the maxLines budget proportionally to each message, -// using filter.TruncateWithRatio for tail-biased (80/20) truncation per message. +// using filter.Truncate for tail-biased truncation per message. func TruncateMessages(messages []store.LogMessage, maxLines int) []store.LogMessage { if maxLines <= 0 { return messages @@ -39,7 +39,7 @@ func TruncateMessages(messages []store.LogMessage, maxLines int) []store.LogMess if share > remaining { share = remaining } - result[i].Lines = filter.TruncateWithRatio(result[i].Lines, share, 0.8) + result[i].Lines = filter.Truncate(result[i].Lines, share) remaining -= len(result[i].Lines) if remaining <= 0 { // Clear remaining messages' lines. From 08807cf2c6b75b8bca1b5e1df86953d9ccdfed95 Mon Sep 17 00:00:00 2001 From: Dennis Zhuang Date: Sun, 8 Feb 2026 01:12:30 +0800 Subject: [PATCH 3/3] chore: apply suggestions Signed-off-by: Dennis Zhuang --- internal/filter/truncate.go | 15 ++++++++++----- internal/filter/truncate_test.go | 15 ++++++++++++++- internal/mcp/collapse.go | 2 +- internal/mcp/collapse_test.go | 6 +++--- 4 files changed, 28 insertions(+), 10 deletions(-) diff --git a/internal/filter/truncate.go b/internal/filter/truncate.go index c1a1ad8..a24abbf 100644 --- a/internal/filter/truncate.go +++ b/internal/filter/truncate.go @@ -13,17 +13,22 @@ const tailRatio = 0.8 // // maxLines <= 0 means no truncation. func Truncate(lines []string, maxLines int) []string { - lines = dedup(lines) - - if maxLines <= 0 || len(lines) <= maxLines { - return lines + if maxLines <= 0 { + return dedup(lines) } // Budget=1: no room for head + omission marker + tail. Just keep the last line. - if maxLines == 1 { + // Done before dedup to avoid returning a "(repeated N times)" marker. + if maxLines == 1 && len(lines) > 1 { return lines[len(lines)-1:] } + lines = dedup(lines) + + if len(lines) <= maxLines { + return lines + } + tail := int(float64(maxLines) * tailRatio) head := maxLines - tail // Ensure at least 1 line on each side. diff --git a/internal/filter/truncate_test.go b/internal/filter/truncate_test.go index 0c6d432..887d10f 100644 --- a/internal/filter/truncate_test.go +++ b/internal/filter/truncate_test.go @@ -60,7 +60,7 @@ func TestTruncateTailBiased(t *testing.T) { } func TestTruncateDistinctLines(t *testing.T) { - // 20 distinct lines, maxLines=6 → with 0.8 tail ratio: 1 head + 4 tail (int(6*0.8)=4) + // 20 distinct lines, maxLines=6 → with 0.8 tail ratio: 2 head + 4 tail (int(6*0.8)=4) lines := make([]string, 20) for i := range lines { lines[i] = fmt.Sprintf("line-%d", i) @@ -100,6 +100,19 @@ func TestTruncateSingleMax(t *testing.T) { } } +func TestTruncateSingleMaxWithDuplicates(t *testing.T) { + // maxLines=1 with repeated lines should return the last real line, + // not a "(repeated N times)" marker from dedup. + lines := []string{"error: foo", "error: foo", "error: foo"} + result := Truncate(lines, 1) + if len(result) != 1 { + t.Fatalf("expected 1 line, got %d: %v", len(result), result) + } + if result[0] != "error: foo" { + t.Errorf("expected last real line, got %q", result[0]) + } +} + func TestDedupConsecutive(t *testing.T) { lines := []string{"a", "a", "a", "b", "b", "c"} result := dedup(lines) diff --git a/internal/mcp/collapse.go b/internal/mcp/collapse.go index a0b67f8..cfa3b26 100644 --- a/internal/mcp/collapse.go +++ b/internal/mcp/collapse.go @@ -84,5 +84,5 @@ func collapseMessage(lineCount int) string { if lineCount == 1 { noun = "line" } - return fmt.Sprintf("(%d %s of output omitted — build succeeded)", lineCount, noun) + return fmt.Sprintf("(%d %s of output omitted — run succeeded)", lineCount, noun) } diff --git a/internal/mcp/collapse_test.go b/internal/mcp/collapse_test.go index 210229d..2aa877c 100644 --- a/internal/mcp/collapse_test.go +++ b/internal/mcp/collapse_test.go @@ -24,7 +24,7 @@ func TestCollapseSuccessful_ExitZero(t *testing.T) { if len(result[0].Lines) != 1 { t.Fatalf("expected 1 line, got %d", len(result[0].Lines)) } - want := "(3 lines of output omitted — build succeeded)" + want := "(3 lines of output omitted — run succeeded)" if result[0].Lines[0] != want { t.Errorf("got %q, want %q", result[0].Lines[0], want) } @@ -152,7 +152,7 @@ func TestCollapseSuccessful_SameTagFailThenSuccess(t *testing.T) { } // Third: collapsed success summary - want := "(3 lines of output omitted — build succeeded)" + want := "(3 lines of output omitted — run succeeded)" if len(result[2].Lines) != 1 || result[2].Lines[0] != want { t.Errorf("success run should be collapsed, got %v", result[2].Lines) } @@ -237,7 +237,7 @@ func TestCollapseSuccessful_EmptyTagDefaultsBuild(t *testing.T) { if len(result) != 1 { t.Fatalf("expected 1 message (collapsed), got %d", len(result)) } - want := "(1 line of output omitted — build succeeded)" + want := "(1 line of output omitted — run succeeded)" if result[0].Lines[0] != want { t.Errorf("got %q, want %q", result[0].Lines[0], want) }