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
19 changes: 15 additions & 4 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. 1237 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. 1249 test functions + 10 fuzz targets, race-detector clean.

## Quick Reference

Expand Down Expand Up @@ -249,13 +249,24 @@ provider is implemented — reaching IMDS from an agent process is a separate se
2. Need ≥4 user messages
3. Head: first 2 user-message boundaries (+3 context after)
4. Tail: last 2 user-message boundaries
5. Middle → LLM summarization under the caller's context → `[COMPRESSED HISTORY]` system message
5. Middle → LLM summarization under the caller's context → `[COMPRESSED HISTORY]` system message;
a **pinned** group is never fed to the summarizer and is re-inserted verbatim after the summary
(`groupPinned`), and a middle that is entirely pinned costs no provider call (roadmap B5)
6. Tool output in middle truncated to 200 chars during serialization
7. Active TODO items re-injected after compression

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) 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

- **`(*Agent) PinLast(n)` / `UnpinLast(n)` / `DropLast(n)` / `PinnedTurns()`** — a *turn* is a user
message plus everything up to the next one, the grouping `compressHistory` works in. Pins ride on
`types.Message.Pinned` and are honoured by the compressor; `/drop` removes turns from the live history
only — the session file is what `--resume` reads and is left alone. The TUI commands are `/pin [n]`,
`/unpin [n]` and `/drop [n]`, refused while a run is active because they replace `Agent.History` while
the run goroutine may append to it.

### `ruleset.go` — Permission Rules

- **`Effect`** (string): `EffectAllow = "allow"`, `EffectDeny = "deny"`
Expand Down Expand Up @@ -858,7 +869,7 @@ User Input (textarea / CLI arg)

## Testing

- **1237 test functions + 10 fuzz targets** in the main module — what `go test ./... -count=1` runs, since `tuiprobe/` is a nested module; each count below is listed with the command that produces it, and no count is carried in a copy that nothing checks
- **1249 test functions + 10 fuzz targets** in the main module — what `go test ./... -count=1` runs, since `tuiprobe/` is a nested module; 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 @@ -904,7 +915,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 # 1237 test functions (main module)
| xargs -0 grep -h '^func Test' | wc -l # 1249 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
- **1237 test functions + 10 fuzz targets** in the main module — the one `go test ./... -count=1` runs, since `tuiprobe/` is a nested module — 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
- **1249 test functions + 10 fuzz targets** in the main module — the one `go test ./... -count=1` runs, since `tuiprobe/` is a nested module — 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
80 changes: 58 additions & 22 deletions agent/compression.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,19 @@ func EstimateMessagesTokens(msgs []types.Message) int {
return total
}

// groupPinned reports whether any message in a group carries a pin. One pinned
// message pins the group: a user instruction whose answer was summarized away
// is not useful on its own, and a turn split in half is not something the
// compressor can reason about.
func groupPinned(msgs []types.Message) bool {
for _, m := range msgs {
if m.Pinned {
return true
}
}
return false
}

// groupEnd returns the index just past the message group containing idx. A
// group starts at a user message and runs until the next user message, so an
// assistant message with tool_calls always stays together with its tool
Expand Down Expand Up @@ -98,29 +111,51 @@ func (a *Agent) compressHistory(ctx context.Context, messages []types.Message) (
return messages, nil
}

var middleText strings.Builder
for _, m := range middle {
switch m.Role {
case types.RoleUser:
middleText.WriteString(fmt.Sprintf("User: %s\n", m.Content))
case types.RoleAssistant:
if m.Content != "" {
middleText.WriteString(fmt.Sprintf("Assistant: %s\n", m.Content))
}
if m.ReasoningContent != "" {
middleText.WriteString(fmt.Sprintf("Reasoning: %s\n", m.ReasoningContent))
}
case types.RoleTool:
content := m.Content
if len(content) > 200 {
content = content[:200] + "..."
// 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]...)
i = end
continue
}
for _, m := range middle[i:end] {
switch m.Role {
case types.RoleUser:
middleText.WriteString(fmt.Sprintf("User: %s\n", m.Content))
case types.RoleAssistant:
if m.Content != "" {
middleText.WriteString(fmt.Sprintf("Assistant: %s\n", m.Content))
}
if m.ReasoningContent != "" {
middleText.WriteString(fmt.Sprintf("Reasoning: %s\n", m.ReasoningContent))
}
case types.RoleTool:
content := m.Content
if len(content) > 200 {
content = content[:200] + "..."
}
middleText.WriteString(fmt.Sprintf("Tool (%s): %s\n", m.Name, content))
case types.RoleSystem:
// Preserve earlier summaries so compressing twice does not discard
// the context that was already distilled.
middleText.WriteString(fmt.Sprintf("Context: %s\n", m.Content))
}
middleText.WriteString(fmt.Sprintf("Tool (%s): %s\n", m.Name, content))
case types.RoleSystem:
// Preserve earlier summaries so compressing twice does not discard
// the context that was already distilled.
middleText.WriteString(fmt.Sprintf("Context: %s\n", m.Content))
}
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.
return messages, nil
}

summaryPrompt := fmt.Sprintf(`Summarize the following conversation history. Focus on:
Expand Down Expand Up @@ -174,9 +209,10 @@ Provide a concise summary in 3-5 sentences.`, middleText.String())
Content: fmt.Sprintf("[COMPRESSED HISTORY — REFERENCE ONLY]\n%s\n[END COMPRESSED HISTORY]", summary),
}

compressed := make([]types.Message, 0, len(head)+1+len(tail))
compressed := make([]types.Message, 0, len(head)+1+len(pinned)+len(tail))
compressed = append(compressed, head...)
compressed = append(compressed, summaryMsg)
compressed = append(compressed, pinned...)
compressed = append(compressed, tail...)

// Inject active todo items after compression so LLM doesn't redo completed work
Expand Down
113 changes: 113 additions & 0 deletions agent/context.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
package agent

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

// Turn-level control over the live history (roadmap B5).
//
// A *turn* is a user message plus everything up to the next user message — the
// same grouping compressHistory works in, so "pin the last turn" and "protect
// this group from the summarizer" mean the same thing.
//
// None of these touch the session store: what is on disk is the record
// `--resume` reads, and a command that silently rewrote it would be a
// destructive edit rather than context control.

// turnStarts returns the index of every user message in history.
func turnStarts(history []types.Message) []int {
var starts []int
for i, msg := range history {
if msg.Role == types.RoleUser {
starts = append(starts, i)
}
}
return starts
}

// turns returns the [start, end) range of every turn, oldest first.
func turns(history []types.Message) [][2]int {
starts := turnStarts(history)
ranges := make([][2]int, 0, len(starts))
for i, start := range starts {
end := len(history)
if i+1 < len(starts) {
end = starts[i+1]
}
ranges = append(ranges, [2]int{start, end})
}
return ranges
}

// lastTurns returns the ranges of the last n turns, oldest first. Asking for
// more turns than exist returns every turn rather than failing: "pin the last 5"
// on a three-turn history should do what it can and report the count.
func lastTurns(history []types.Message, n int) [][2]int {
ranges := turns(history)
if n <= 0 || len(ranges) == 0 {
return nil
}
if n > len(ranges) {
n = len(ranges)
}
return ranges[len(ranges)-n:]
}

// PinLast marks the last n turns as pinned and returns how many turns it pinned
// and how many are pinned in total.
func (a *Agent) PinLast(n int) (pinned, total int) {
for _, r := range lastTurns(a.History, n) {
for i := r[0]; i < r[1]; i++ {
a.History[i].Pinned = true
}
pinned++
}
return pinned, a.PinnedTurns()
}

// UnpinLast clears the pin on the last n pinned turns, newest first, and returns
// how many it cleared and how many remain.
func (a *Agent) UnpinLast(n int) (unpinned, remaining int) {
if n <= 0 {
n = 1
}
ranges := turns(a.History)
for i := len(ranges) - 1; i >= 0 && unpinned < n; i-- {
cleared := false
for j := ranges[i][0]; j < ranges[i][1]; j++ {
if a.History[j].Pinned {
a.History[j].Pinned = false
cleared = true
}
}
if cleared {
unpinned++
}
}
return unpinned, a.PinnedTurns()
}

// DropLast removes the last n turns from the live history and returns how many
// turns went. The session file is untouched.
func (a *Agent) DropLast(n int) (dropped int) {
ranges := lastTurns(a.History, n)
if len(ranges) == 0 {
return 0
}
// The copy matters: history is shared with any goroutine holding a slice of
// it, and truncating in place would overwrite what they are reading.
a.History = append([]types.Message(nil), a.History[:ranges[0][0]]...)
return len(ranges)
}

// PinnedTurns reports how many turns carry a pin.
func (a *Agent) PinnedTurns() int {
count := 0
for _, r := range turns(a.History) {
for i := r[0]; i < r[1]; i++ {
if a.History[i].Pinned {
count++
break
}
}
}
return count
}
Loading
Loading