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..a24abbf 100644 --- a/internal/filter/truncate.go +++ b/internal/filter/truncate.go @@ -2,24 +2,44 @@ package filter import "fmt" +// 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 + // 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. +// - Merges consecutive duplicate lines into "(repeated N times)" markers. +// - If lines exceed maxLines, keeps head and tail with an omission notice in between. +// - Tail-biased: 80% of the budget goes to the tail where errors typically appear. +// // maxLines <= 0 means no truncation. func Truncate(lines []string, maxLines int) []string { + if maxLines <= 0 { + return dedup(lines) + } + + // Budget=1: no room for head + omission marker + tail. Just keep the last line. + // 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 maxLines <= 0 || len(lines) <= maxLines { + if len(lines) <= maxLines { return lines } - // Keep roughly half at the head and half at the tail - head := maxLines / 2 - tail := maxLines - head + tail := int(float64(maxLines) * tailRatio) + head := maxLines - tail + // Ensure at least 1 line on each side. if tail == 0 { tail = 1 head = maxLines - 1 } + if head == 0 { + 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..887d10f 100644 --- a/internal/filter/truncate_test.go +++ b/internal/filter/truncate_test.go @@ -35,8 +35,32 @@ func TestTruncateWithDuplicates(t *testing.T) { } } +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, 10) + + // 2 head + 1 omission + 8 tail = 11 entries + if len(result) != 11 { + t.Fatalf("expected 11 lines, got %d: %v", len(result), result) + } + if result[0] != "line-0" || result[1] != "line-1" { + t.Errorf("head: got %q, %q", result[0], result[1]) + } + if result[2] != "... (10 lines omitted)" { + t.Errorf("omission: got %q", result[2]) + } + if result[3] != "line-12" || result[10] != "line-19" { + t.Errorf("tail: got first=%q last=%q", result[3], result[10]) + } +} + func TestTruncateDistinctLines(t *testing.T) { - // 20 distinct lines should be truncated to head + omission + tail + // 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) @@ -44,19 +68,48 @@ func TestTruncateDistinctLines(t *testing.T) { 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 - } + // 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 !found { - t.Errorf("expected omission marker in result: %v", result) + if result[2] != "... (14 lines omitted)" { + t.Errorf("expected omission marker, got %q", result[2]) } - if len(result) > 7 { - t.Errorf("expected <= 7 lines, got %d", len(result)) + // 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 TestTruncateSingleLine(t *testing.T) { + result := Truncate([]string{"only"}, 1) + if len(result) != 1 || result[0] != "only" { + t.Errorf("unexpected result: %v", result) + } +} + +func TestTruncateSingleMax(t *testing.T) { + lines := []string{"a", "b", "c"} + 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) + } + if result[0] != "c" { + t.Errorf("expected last line %q, got %q", "c", result[0]) + } +} + +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]) } } @@ -89,10 +142,3 @@ func TestDedupEmpty(t *testing.T) { 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) - } -} diff --git a/internal/mcp/collapse.go b/internal/mcp/collapse.go new file mode 100644 index 0000000..cfa3b26 --- /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 — run succeeded)", lineCount, noun) +} diff --git a/internal/mcp/collapse_test.go b/internal/mcp/collapse_test.go new file mode 100644 index 0000000..2aa877c --- /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 — run 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 — 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) + } +} + +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 — run 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..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.Truncate for smart head/tail 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 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.