Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions CODEBASE.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# TinyCode — CODEBASE Map

> AI coding agent in pure Go. Single binary, Bubble Tea TUI, ReAct agent loop, 25 built-in tools + MCP, LSP diagnostics, session persistence. 1277 test functions + 10 fuzz targets, race-detector clean.
> AI coding agent in pure Go. Single binary, Bubble Tea TUI, ReAct agent loop, 25 built-in tools + MCP, LSP diagnostics, session persistence. 1285 test functions + 10 fuzz targets, race-detector clean.

## Quick Reference

Expand Down Expand Up @@ -258,6 +258,8 @@ provider is implemented — reaching IMDS from an agent process is a separate se
A summarizer failure (including `context.Canceled`) returns `nil, err` rather than a no-op history, so a caller can tell a cancelled summary from a healthy "nothing to compress". The agent loop ignores the returned history in that case, exactly as it did when the error was swallowed, but `CompressHistory` surfaces it to the TUI.
- **`(*Agent) compactRunMessages(ctx, messages, step)`** (`compaction.go`) — the *run's own* traffic is compacted at a step boundary when it crosses the same threshold; the cut snaps forward to an assistant message so a `tool_calls` message never loses its results. The head (system prompt, history, this run's prompt) and the last eight messages are kept verbatim; the rest becomes `[COMPACTED RUN — REFERENCE ONLY]`. A failure logs and leaves the messages alone.
- **`(*Agent) summarizeText(...)`** — the only place compression talks to a provider, and it calls `recordCall`, so the summary's tokens and cost reach `UsageTotal`, the budgets and the status bar (they were invisible before).
- **`compressionPlan` / `(*Agent) planCompression(messages)`** — the partition (head, middle, pinned, tail) plus the token estimate and the threshold, computed **without a model call**. `compressHistory` executes this plan rather than re-deriving it, so `/compress preview` cannot disagree with the compression it previews.
- **`(*Agent) CompressionPreview()`** — the report behind `/compress preview`: whether automatic compression would fire, each kept region with its first lines and token count, and the middle's size. No provider call; refused while a run is active, because reading a slice the run appends to is still a data race.
- **`(*Agent) HandleContextError(err error) bool`** — detects `context_length_exceeded`, lowers `discoveredCtxLen` (5 regex patterns, valid range 1024–10M, only lowers never raises)

### `context.go` — Turn-level Context Control
Expand Down Expand Up @@ -871,7 +873,7 @@ User Input (textarea / CLI arg)

## Testing

- **1277 test functions + 10 fuzz targets** across the repository — what `go test ./... -count=1` runs for the main module, plus the evaluation corpora under `eval/` (nested modules the command still walks); each count below is listed with the command that produces it, and no count is carried in a copy that nothing checks
- **1285 test functions + 10 fuzz targets** across the repository — what `go test ./... -count=1` runs for the main module, plus the evaluation corpora under `eval/` (nested modules the command still walks); each count below is listed with the command that produces it, and no count is carried in a copy that nothing checks
- `make fuzz` (`FUZZTIME=30s`) explores every fuzz target; `go test` already runs their seed corpora, so CI exercises them on every push
- `make test-browser` (`BROWSER_TEST=1`, `-run TestBrowserSmoke`) renders a JavaScript page through both browser paths against a loopback server and asserts the request carried the proxy's `Via` header, which proves the filtering proxy was used; `TestBrowserSmokeRefusesABlockedSubresource` then serves a page whose own authority is exempted from the loopback rule while the policy stays enforced, and asserts a subresource pointing at a *second, live* loopback service is refused (the script's `onerror` marker in the DOM) and that the service was never reached — a refusal is proven, not only that the proxy was used (issue #8). Both skip without a browser, so `make test` never launches one
- `make test-tui-visual` (`TUI_SHOT=1`) is the only place the TUI is looked at rather than asserted on: it renders the committed frame scenarios to PNGs through headless Chromium, starts `bin/tinycode` on a real 80x24 PTY (asserting the stream carried SGR styling and that the binary quits on the documented double Ctrl+C), runs it once more on a size-less PTY, and replays the live stream into a screen buffer that is screenshotted as `tinycode-pty-welcome-80x24.png`. Both tests skip without the variable, so `make test` needs neither a browser, nor a binary, nor a terminal device; PNGs land in `TUI_SHOT_DIR` (default `/tmp`)
Expand Down Expand Up @@ -917,7 +919,7 @@ Counts in this document are produced by these commands at the commit they descri

```bash
find . -name '*_test.go' -not -path './tuiprobe/*' -print0 \
| xargs -0 grep -h '^func Test' | wc -l # 1277 test functions (main module)
| xargs -0 grep -h '^func Test' | wc -l # 1285 test functions (main module)
grep -rn '^func Fuzz' --include=*_test.go . | wc -l # 10 fuzz targets
ls tui/testdata/golden/frames/*.txt | wc -l # 29 plain-text frames
ls tui/testdata/golden/ansi/*.ansi | wc -l # 1 raw ANSI frame
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ Custom **CellGrid** frame-buffer renders markdown directly in the terminal — n
- **GitHub Actions**: Two workflows — main.yml (build + lint + test on push/PR) and release.yml (cross-compile + GitHub Releases on tags v*)
- **main.yml jobs**: `ci` (build, gofmt gate, vet, tests, `-race`, repeated run), `lsp` (real gopls), `browser` (real Chromium), `tui-visual` (frame PNGs + the built binary on a PTY), `cross` (linux/amd64, linux/arm64, darwin/arm64 build + vet) and `staticcheck`
- **Makefile improvements**: test target preserves exit code with pass/fail message; releases target cross-compiles all platforms + .tar.gz archives
- **1277 test functions + 10 fuzz targets** across the repository — the main module plus the evaluation corpora, with `tuiprobe/` a nested module the command below excludes — counted with `find . -name '*_test.go' -not -path './tuiprobe/*' -print0 | xargs -0 grep -h '^func Test' | wc -l` and `grep -rn '^func Fuzz' --include=*_test.go . | wc -l`; the command is part of the record, and `CODEBASE.md` → Testing lists every count with its measurement
- **1285 test functions + 10 fuzz targets** across the repository — the main module plus the evaluation corpora, with `tuiprobe/` a nested module the command below excludes — counted with `find . -name '*_test.go' -not -path './tuiprobe/*' -print0 | xargs -0 grep -h '^func Test' | wc -l` and `grep -rn '^func Fuzz' --include=*_test.go . | wc -l`; the command is part of the record, and `CODEBASE.md` → Testing lists every count with its measurement
- **Annotations**: the jobs carry one `ubuntu-latest` migration notice each, plus the `setup-chrome@v1` Node 20 warning on `browser` and `tui-visual`; the gate is no *new* annotations, not zero

### Running the checks locally
Expand Down
76 changes: 51 additions & 25 deletions agent/compression.go
Original file line number Diff line number Diff line change
Expand Up @@ -132,26 +132,45 @@ func groupEnd(messages []types.Message, idx int) int {
return len(messages)
}

// compressHistory compresses a.History when it exceeds the threshold. It
// returns the history unchanged (and nil error) when there is nothing to
// compress, and a nil history plus the summarizer's error when the provider
// call fails or the context is cancelled.
func (a *Agent) compressHistory(ctx context.Context, messages []types.Message) ([]types.Message, error) {
// compressionPlan is the partition compressHistory makes, computed without a
// model call. The preview and the compression both read it, so they cannot
// disagree about what is kept (roadmap B5, S3).
type compressionPlan struct {
// Head and Tail are kept verbatim: the first two user groups and the last two.
Head []types.Message
Tail []types.Message
// Middle is the unpinned middle — what a summarizer would be given.
Middle []types.Message
// Pinned are middle groups the user asked to keep verbatim.
Pinned []types.Message
// Tokens is the history's estimated size and Threshold the bound that decides
// whether automatic compression fires.
Tokens int
Threshold int
}

// OverThreshold reports whether automatic compression would fire.
func (p compressionPlan) OverThreshold() bool {
return p.Threshold > 0 && p.Tokens >= p.Threshold
}

// planCompression works out what a compression of these messages would keep and
// what it would summarize. The second result is the reason there is no plan, so
// a caller can say why instead of "nothing to do".
func (a *Agent) planCompression(messages []types.Message) (compressionPlan, string) {
if a.CompressionThreshold <= 0 || a.ContextLength <= 0 {
return messages, nil
}
estimatedTokens := EstimateMessagesTokens(messages)
if estimatedTokens < a.CompressionThreshold {
return messages, nil
return compressionPlan{}, "compression is disabled"
}
plan := compressionPlan{Tokens: EstimateMessagesTokens(messages), Threshold: a.CompressionThreshold}

var userMsgIndices []int
for i, m := range messages {
if m.Role == types.RoleUser {
userMsgIndices = append(userMsgIndices, i)
}
}
if len(userMsgIndices) < 4 {
return messages, nil
return compressionPlan{}, "the history has fewer than four user turns"
}
protectFirst := 2
if protectFirst > len(userMsgIndices) {
Expand All @@ -168,39 +187,46 @@ func (a *Agent) compressHistory(ctx context.Context, messages []types.Message) (
tailBudget := 2
tailStart := userMsgIndices[len(userMsgIndices)-tailBudget]
if tailStart <= headEnd {
return messages, nil
return compressionPlan{}, "the head and the tail overlap: there is no middle to compress"
}
head := messages[:headEnd]
middle := messages[headEnd:tailStart]
tail := messages[tailStart:]
if len(middle) < 2 {
return messages, nil
return compressionPlan{}, "the middle is too small to be worth a summary"
}
plan.Head = messages[:headEnd]
plan.Tail = messages[tailStart:]

// A pinned group is context the user asked to keep verbatim: it is never
// fed to the summarizer, and it is put back after the summary and before the
// tail. The order is therefore head, summary, pinned, recent tail — a
// summary is already a lossy stand-in, and keeping the pinned text at all
// matters more than where in the stand-in it sits (roadmap B5).
var (
middleText strings.Builder
pinned []types.Message
)
for i := 0; i < len(middle); {
end := groupEnd(middle, i)
if groupPinned(middle[i:end]) {
pinned = append(pinned, middle[i:end]...)
plan.Pinned = append(plan.Pinned, middle[i:end]...)
i = end
continue
}
middleText.WriteString(describeMessages(middle[i:end]))
plan.Middle = append(plan.Middle, middle[i:end]...)
i = end
}
if strings.TrimSpace(middleText.String()) == "" {
// Everything worth compressing is pinned. Compressing it would mean
// paying for a summary of nothing, so leave the history alone.
if len(plan.Middle) == 0 {
return compressionPlan{}, "every turn in the middle is pinned, so a summary would be of nothing"
}
return plan, ""
}

// compressHistory compresses a.History when it exceeds the threshold. It
// returns the history unchanged (and nil error) when there is nothing to
// compress, and a nil history plus the summarizer's error when the provider
// call fails or the context is cancelled.
func (a *Agent) compressHistory(ctx context.Context, messages []types.Message) ([]types.Message, error) {
plan, _ := a.planCompression(messages)
if plan.Threshold == 0 || !plan.OverThreshold() {
return messages, nil
}
head, middleText, pinned, tail := plan.Head, describeMessages(plan.Middle), plan.Pinned, plan.Tail

const instruction = `Summarize the following conversation history. Focus on:
- What tasks were completed
Expand All @@ -213,7 +239,7 @@ Conversation to summarize:`
// A summarizer failure is reported rather than swallowed: the caller keeps
// its history either way, but only this way can it tell a cancelled run
// (context.Canceled) from a healthy no-op.
summary, err := a.summarizeText(ctx, instruction, middleText.String(), 1024, 0)
summary, err := a.summarizeText(ctx, instruction, middleText, 1024, 0)
if err != nil {
return nil, err
}
Expand Down
73 changes: 73 additions & 0 deletions agent/compression_preview.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package agent

import (
"fmt"
"strings"
"unicode/utf8"

"github.com/yusiwen/tinycode/types"
)

// previewLines bounds how many messages one section lists. The preview is meant
// to be read in a terminal, not to reproduce the history.
const previewLines = 12

// CompressionPreview describes what a compression of the current history would
// keep and what it would summarize, without calling a model (roadmap B5, S3).
//
// It reads the same partition compressHistory uses — planCompression — so the
// preview cannot disagree with the thing it previews about which turns are kept.
func (a *Agent) CompressionPreview() string {
plan, reason := a.planCompression(a.History)
if plan.Threshold == 0 {
return "Compression preview: nothing to show — " + reason + "."
}
var b strings.Builder
state := "would not fire (under the threshold)"
if plan.OverThreshold() {
state = "would fire"
}
fmt.Fprintf(&b, "Compression preview (no model call): %d messages, ~%d tokens, threshold %d — automatic compression %s.\n",
len(a.History), plan.Tokens, plan.Threshold, state)
if reason != "" {
fmt.Fprintf(&b, "Nothing would be compressed: %s.\n", reason)
return b.String()
}
writePreviewSection(&b, "kept: head", plan.Head)
fmt.Fprintf(&b, "summarized: middle — %d messages, ~%d tokens\n",
len(plan.Middle), EstimateMessagesTokens(plan.Middle))
writePreviewSection(&b, "kept: pinned", plan.Pinned)
writePreviewSection(&b, "kept: tail", plan.Tail)
return b.String()
}

// writePreviewSection lists one kept region: its size and the first line of each
// message, capped so the preview stays a glance.
func writePreviewSection(b *strings.Builder, label string, messages []types.Message) {
if len(messages) == 0 {
return
}
fmt.Fprintf(b, "%s — %d messages, ~%d tokens\n", label, len(messages), EstimateMessagesTokens(messages))
for i, msg := range messages {
if i == previewLines {
fmt.Fprintf(b, " … (%d more)\n", len(messages)-i)
break
}
fmt.Fprintf(b, " %s: %s\n", msg.Role, firstLine(msg.Content, 60))
}
}

// firstLine returns the first non-empty line of text, clipped to max runes.
func firstLine(text string, max int) string {
trimmed := strings.TrimSpace(text)
for _, line := range strings.Split(trimmed, "\n") {
if line = strings.TrimSpace(line); line != "" {
if utf8.RuneCountInString(line) > max {
runes := []rune(line)
return string(runes[:max]) + "…"
}
return line
}
}
return "(no text)"
}
Loading
Loading