From 84e79e22ce0ba9c0d26c2ffd59e54fece635f370 Mon Sep 17 00:00:00 2001 From: Siwen Yu Date: Sat, 31 Oct 2026 22:28:19 +0800 Subject: [PATCH 1/2] feat(agent): keep a file's reads out of compaction (roadmap B5, S4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The last increment of B5, and the one its acceptance had to be rewritten for: `/drop ` cannot remove a run's tool results, because those live inside Run and the TUI refuses commands while a run is active. The decision therefore moves to where it can be made — before the run. `Agent.KeptPaths` names files whose tool reads must survive compaction. `compactRunMessages` walks the rounds it would summarize and keeps any round whose `tool_calls` arguments mention a kept path, verbatim, after the compaction marker and before the recent tail. A run whose every round is kept is left alone and costs no summary call. The list travels with the session (`session.Session.KeptPaths`), so `--resume` keeps the rule. Commands: `/keep `, `/keep` (list), `/unkeep `, all refused while a run is active — the run's compaction reads that list. Verified: go test ./... -count=1 and -race, 16 packages ok; go vet and gofmt clean; both load-bearing paths mutation-checked — disabling the kept-round check fails "the kept read was summarized away", and dropping the persist line fails "the session kept []". Refs #306, #168 --- CODEBASE.md | 7 +- README.md | 2 +- agent/agent.go | 4 + agent/compaction.go | 45 +++++++++- agent/compaction_test.go | 74 ++++++++++++++++ agent/context.go | 58 ++++++++++++- agent/context_test.go | 24 ++++++ docs/roadmap.md | 15 +++- session/session.go | 5 ++ tui/context_command.go | 39 +++++++++ tui/context_command_test.go | 85 +++++++++++++++++++ tui/model.go | 4 + tui/testdata/golden/frames/palette_120x40.txt | 2 + tui/testdata/golden/frames/palette_30x12.txt | 4 + tui/testdata/golden/frames/palette_40x12.txt | 4 + tui/testdata/golden/frames/palette_80x24.txt | 2 + tui/update.go | 7 +- 17 files changed, 369 insertions(+), 12 deletions(-) diff --git a/CODEBASE.md b/CODEBASE.md index 3b64f7f..37c43db 100644 --- a/CODEBASE.md +++ b/CODEBASE.md @@ -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. 1285 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. 1291 test functions + 10 fuzz targets, race-detector clean. ## Quick Reference @@ -264,6 +264,7 @@ provider is implemented — reaching IMDS from an agent process is a separate se ### `context.go` — Turn-level Context Control +- **`(*Agent) KeepPath(p)` / `UnkeepPath(p)` / `keepsPath(args)`** — a kept path is one whose tool reads must survive the compaction of a run: `compactRunMessages` keeps any round whose `tool_calls` arguments name it and summarizes the rest. Set before a run; the list travels with the session (`session.Session.KeptPaths`) so a resume keeps the rule. - **`(*Agent) PinLast(n)` / `UnpinLast(n)` / `DropLast(n)` / `PinnedTurnTexts()` / `PinnedTurnCount()`** — 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 @@ -873,7 +874,7 @@ User Input (textarea / CLI arg) ## Testing -- **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 +- **1291 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`) @@ -919,7 +920,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 # 1285 test functions (main module) + | xargs -0 grep -h '^func Test' | wc -l # 1291 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 diff --git a/README.md b/README.md index f18b9cc..4200021 100644 --- a/README.md +++ b/README.md @@ -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 -- **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 +- **1291 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 diff --git a/agent/agent.go b/agent/agent.go index a66f6b6..5d1185f 100644 --- a/agent/agent.go +++ b/agent/agent.go @@ -29,6 +29,10 @@ type Agent struct { // MemoryRecallLimit is how many facts the recall step injects; zero means // DefaultMemoryRecallLimit. It comes from memory.recall_limit. MemoryRecallLimit int + // KeptPaths name files whose tool reads survive the compaction of a run + // (roadmap B5, S4). Set before a run; the TUI persists the list with the + // session so a resume keeps the rule. + KeptPaths []string // Session persistence SessionStore interface { Append(msg types.Message) error diff --git a/agent/compaction.go b/agent/compaction.go index fafffcf..177c2a5 100644 --- a/agent/compaction.go +++ b/agent/compaction.go @@ -9,6 +9,18 @@ import ( "github.com/yusiwen/tinycode/types" ) +// roundKeepsPath reports whether any tool call in a round names a kept path. +func (a *Agent) roundKeepsPath(round []types.Message) bool { + for _, msg := range round { + for _, call := range msg.ToolCalls { + if a.keepsPath(call.Arguments) { + return true + } + } + } + return false +} + // compactRunMessages compresses the *run's own* traffic — the assistant and tool // messages between the prompt and now — when it crosses the compression // threshold (roadmap B5, S2). @@ -53,13 +65,41 @@ func (a *Agent) compactRunMessages(ctx context.Context, messages []types.Message return messages, false } + // A round whose tool call names a kept path is context the user asked to + // keep: it is not summarized, and it is put back after the marker and before + // the recent tail (roadmap B5, S4). + var ( + text strings.Builder + kept []types.Message + ) + for i := 0; i < cut; { + end := i + 1 + if rest[i].Role == types.RoleAssistant { + for end < cut && rest[end].Role == types.RoleTool { + end++ + } + } + round := rest[i:end] + if round[0].Role == types.RoleAssistant && a.roundKeepsPath(round) { + kept = append(kept, round...) + } else { + text.WriteString(describeMessages(round)) + } + i = end + } + if strings.TrimSpace(text.String()) == "" { + // Everything in the compactable part is kept: a summary would be of + // nothing, so leave the run alone. + return messages, false + } + const instruction = `Summarize what this run has done so far. Focus on: - The tools that were called and what they established - Files read or modified, and why - What is still open Run so far:` - summary, err := a.summarizeText(ctx, instruction, describeMessages(rest[:cut]), 1024, step) + summary, err := a.summarizeText(ctx, instruction, text.String(), 1024, step) if err != nil { // A failed compaction costs the run one step of context, not the run: // the messages stay as they were and the loop continues. @@ -70,12 +110,13 @@ Run so far:` return messages, false } - out := make([]types.Message, 0, len(messages)-cut+1) + out := make([]types.Message, 0, len(messages)-cut+1+len(kept)) out = append(out, messages[:prompt+1]...) out = append(out, types.Message{ Role: types.RoleSystem, Content: fmt.Sprintf("[COMPACTED RUN — REFERENCE ONLY]\n%s\n[END COMPACTED RUN]", summary), }) + out = append(out, kept...) out = append(out, rest[cut:]...) tlog.Info("agent.loop", "compacted run messages", "summarized", cut, "kept", len(rest)-cut, "step", step) return out, true diff --git a/agent/compaction_test.go b/agent/compaction_test.go index 08a121c..64aa616 100644 --- a/agent/compaction_test.go +++ b/agent/compaction_test.go @@ -190,3 +190,77 @@ func TestRunCompactsItsOwnMessages(t *testing.T) { t.Fatalf("no provider request carried a compacted run across %d calls", len(provider.requests)) } } + +// keptRunMessages builds a run whose second round reads a file the test keeps, +// followed by filler rounds and a recent tail. +func keptRunMessages() []types.Message { + messages := []types.Message{ + {Role: types.RoleSystem, Content: "system prompt"}, + {Role: types.RoleUser, Content: "do the thing"}, + } + round := func(id, path, output string) []types.Message { + return []types.Message{ + {Role: types.RoleAssistant, ToolCalls: []types.ToolCall{{ + ID: id, Name: "read_file", Arguments: `{"path":"` + path + `"}`, + }}}, + {Role: types.RoleTool, ToolCallID: id, Name: "read_file", Content: output}, + } + } + messages = append(messages, round("1", "internal/filler.go", "filler one")...) + messages = append(messages, round("2", "internal/kept.go", "KEPT CONTENT")...) + for i := 0; i < 4; i++ { + messages = append(messages, round(string(rune('3'+i)), "internal/filler.go", "filler round")...) + } + return messages +} + +// TestCompactRunMessagesKeepsPathsTheUserKept is S4's acceptance: a read of a +// kept path survives compaction verbatim and the summary does not absorb it, +// while a read of any other path is summarized away as before. +func TestCompactRunMessagesKeepsPathsTheUserKept(t *testing.T) { + provider := &recordingProvider{content: "RUN SUMMARY"} + ag := New(provider) + ag.ContextLength = 1000 + ag.CompressionThreshold = 1 + if !ag.KeepPath("internal/kept.go") { + t.Fatal("the path was not kept") + } + + in := keptRunMessages() + out, changed := ag.compactRunMessages(context.Background(), in, 2) + if !changed { + t.Fatal("the run was not compacted") + } + if !containsMessage(out, "KEPT CONTENT") { + t.Fatal("the kept read was summarized away") + } + if containsMessage(out, "filler one") { + t.Fatal("an unkept read survived the summary") + } + if !containsMessage(out, "RUN SUMMARY") { + t.Fatal("the summary is missing") + } + if prompt := provider.requests[0].Messages[1].Content; strings.Contains(prompt, "KEPT CONTENT") { + t.Fatalf("the summarizer was given the kept read:\n%s", prompt) + } +} + +// TestCompactRunMessagesDoesNothingWhenEveryRoundIsKept: if every read is kept, +// a summary would be of nothing, so the run is left alone and no call is paid. +func TestCompactRunMessagesDoesNothingWhenEveryRoundIsKept(t *testing.T) { + provider := &recordingProvider{content: "RUN SUMMARY"} + ag := New(provider) + ag.ContextLength = 1000 + ag.CompressionThreshold = 1 + ag.KeepPath("internal/filler.go") + ag.KeepPath("internal/kept.go") + + in := keptRunMessages() + out, changed := ag.compactRunMessages(context.Background(), in, 2) + if changed { + t.Fatalf("a fully kept run was compacted: %d -> %d messages", len(in), len(out)) + } + if len(provider.requests) != 0 { + t.Fatalf("the summarizer was called %d times for nothing", len(provider.requests)) + } +} diff --git a/agent/context.go b/agent/context.go index 4137c2c..6b38110 100644 --- a/agent/context.go +++ b/agent/context.go @@ -1,6 +1,62 @@ package agent -import "github.com/yusiwen/tinycode/types" +import ( + "path/filepath" + "strings" + + "github.com/yusiwen/tinycode/types" +) + +// Path-level control over compaction (roadmap B5, S4). +// +// A run's messages hold the tool results, so "unload this file" is a decision +// about those. `/drop ` cannot make it — the messages live inside Run and +// the TUI refuses commands while a run is active — so the decision is made +// before the run instead: a kept path's reads survive compaction verbatim, and +// every other read is summarized away as usual. + +// KeepPath adds a path whose tool reads must survive compaction. The path is +// cleaned and stored once; an empty path is ignored. +func (a *Agent) KeepPath(path string) (added bool) { + path = filepath.Clean(strings.TrimSpace(path)) + if path == "" || path == "." { + return false + } + for _, kept := range a.KeptPaths { + if kept == path { + return false + } + } + a.KeptPaths = append(a.KeptPaths, path) + return true +} + +// UnkeepPath removes a path, reporting whether it was there. +func (a *Agent) UnkeepPath(path string) (removed bool) { + path = filepath.Clean(strings.TrimSpace(path)) + for i, kept := range a.KeptPaths { + if kept != path { + continue + } + a.KeptPaths = append(a.KeptPaths[:i], a.KeptPaths[i+1:]...) + return true + } + return false +} + +// keepsPath reports whether a tool call's arguments name a kept path. Arguments +// are the signal because that is where a read names the file it reads. +func (a *Agent) keepsPath(arguments string) bool { + if arguments == "" { + return false + } + for _, kept := range a.KeptPaths { + if kept != "" && strings.Contains(arguments, kept) { + return true + } + } + return false +} // Turn-level control over the live history (roadmap B5). // diff --git a/agent/context_test.go b/agent/context_test.go index d93508b..071aaa9 100644 --- a/agent/context_test.go +++ b/agent/context_test.go @@ -194,3 +194,27 @@ func TestPinnedTurnTexts(t *testing.T) { t.Fatalf("PinnedTurnCount = %d, want 1", got) } } + +// TestKeepPathCleansAndDeduplicates pins the list's shape: cleaned, stored once, +// and empty values ignored. +func TestKeepPathCleansAndDeduplicates(t *testing.T) { + ag := New(nil) + if !ag.KeepPath(" internal/store.go ") { + t.Fatal("a path was not kept") + } + if ag.KeepPath("internal/store.go") { + t.Fatal("the same path was kept twice") + } + if ag.KeepPath(" ") { + t.Fatal("an empty path was kept") + } + if len(ag.KeptPaths) != 1 || ag.KeptPaths[0] != "internal/store.go" { + t.Fatalf("kept paths = %v", ag.KeptPaths) + } + if !ag.UnkeepPath("internal/store.go") { + t.Fatal("the path was not dropped") + } + if ag.UnkeepPath("internal/store.go") { + t.Fatal("dropping a path twice reported success") + } +} diff --git a/docs/roadmap.md b/docs/roadmap.md index c58a295..4a80c2d 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -16,9 +16,9 @@ after it (see *Provenance and method*). | State | Entries | Note | | :--- | ---: | :--- | -| Delivered | 16 | the entry's **Picked up** line names the issue that landed it | +| Delivered | 17 | the entry's **Picked up** line names the issue that landed it | | Refuted | 1 | E5 — the capability exists, recorded so it is not re-filed | -| In progress | 2 | F1 — three increments landed, the next one is not opened; B5 — S1 landed, S2 and S3 open | +| In progress | 1 | F1 — three increments landed, the next one is not opened | | Not started | 21 | verified gaps, no issue opened | | **Total** | **40** | `grep -Ec '^\| [A-H][0-9] \| .* \| (Delivered —\|Refuted\|In progress\|Not started)' docs/roadmap.md` | @@ -43,7 +43,7 @@ B4 (repository index), F1, then the larger D and E items. | B2 | No local memory backend | Delivered — #293 | | B3 | No embeddings, vector search or rerank | Delivered — #297 | | B4 | No repository index or repo map | Not started — spiked 2026-10-28, no-go (#308) | -| B5 | Context management has a single strategy | In progress — #306 | +| B5 | Context management has a single strategy | Delivered — #306 | | B6 | Truncation is fixed, not budget-aware | Delivered — #304 | | C1 | No checkpoint and no undo | Delivered — #234 | | C2 | No automatic change preview and commit loop, and no remote operations | Not started | @@ -395,7 +395,14 @@ current tree. D7 is the exception: it was recorded later, so its references are own messages — the part D7 depends on — the summarizer's spend now counted, and pins carried through the session file), S3 (a compression preview that calls no model — it reads the same partition `compressHistory` executes), S4 (a path-targeted unload, split out of S2 because the command channel it - assumed does not exist). **S1–S3 landed** (2026-10-28, 2026-10-31); S4 is open. + assumed does not exist). **S1–S4 landed** (2026-10-28 … 2026-10-31); the entry is delivered. +- **Delivered (2026-10-31)**: the single strategy is now four surfaces — turn-level `/pin`, `/unpin` and + `/drop` (pins survive compression and a resume), compaction of the run's own messages at a step + boundary, `/compress preview` (it reads the same plan the compression executes, and makes no model + call), and `/keep `, which keeps a file's tool reads verbatim through a compaction. The + summarizer's spend, which used to be invisible to the budgets, is counted. The one thing not built is a + *mid-run* command to unload a file: the run owns its messages and the TUI refuses commands while it + runs, so the rule is set before the run instead — recorded in #306's S4. - **Gap**: compression is automatic and all-or-nothing. No `/drop`, no `/pin`, no unloading by message or by file, and no preview of what a compression would discard. - **Evidence**: one strategy, `compressHistory` (`agent/compression.go:59`) — head, LLM-summarized diff --git a/session/session.go b/session/session.go index 8fdf04d..d78525d 100644 --- a/session/session.go +++ b/session/session.go @@ -38,6 +38,11 @@ type Session struct { // Permission paths allowed for this session (Allow session) AllowedPaths []string `json:"allowed_paths,omitempty"` + // KeptPaths are files whose tool reads must survive compaction of a run + // (roadmap B5, S4). They live with the session because the rule is the + // user's, and a resume has to keep it. + KeptPaths []string `json:"kept_paths,omitempty"` + // Containment records what enforced file boundaries when this session ran, // as reported by the tool package at creation ("kernel (…)", // "userspace (…)"). It is recorded once because it is a property of the diff --git a/tui/context_command.go b/tui/context_command.go index 6031964..bec24ae 100644 --- a/tui/context_command.go +++ b/tui/context_command.go @@ -40,6 +40,11 @@ func (m *TuiModel) handleContextCommand(cmd, name string) (tea.Model, tea.Cmd) { m.ShowStatus("Cannot " + strings.TrimPrefix(name, "/") + ": a run is in progress") return m, nil } + if name == "/keep" || name == "/unkeep" { + m.handleKeep(cmd, name) + return m, nil + } + n, err := contextTurns(cmd, name) if err != nil { m.appendSystem(err.Error()) @@ -106,3 +111,37 @@ func (m *TuiModel) syncPinsToTranscript() { } } } + +// handleKeep manages the paths whose tool reads survive compaction (S4 of #306). +// The rule is set before a run — that is why it is a command and not something a +// mid-run model call decides — and it is persisted with the session. +func (m *TuiModel) handleKeep(cmd, name string) { + arg := strings.TrimSpace(strings.TrimPrefix(cmd, name)) + if name == "/keep" && arg == "" { + if len(m.agent.KeptPaths) == 0 { + m.appendSystem("no kept paths; /keep keeps a file's reads in context across a compaction") + return + } + m.appendSystem("kept paths (their reads survive compaction):\n " + strings.Join(m.agent.KeptPaths, "\n ")) + return + } + if arg == "" { + m.appendSystem("usage: /unkeep ") + return + } + if name == "/keep" { + if !m.agent.KeepPath(arg) { + m.appendSystem("already kept: " + arg) + return + } + m.persistSession() + m.appendSystem("kept " + arg + ": a tool call that names it is kept verbatim when the run is compacted") + return + } + if !m.agent.UnkeepPath(arg) { + m.appendSystem("not kept: " + arg) + return + } + m.persistSession() + m.appendSystem("dropped " + arg + " from the kept paths") +} diff --git a/tui/context_command_test.go b/tui/context_command_test.go index 159f1c9..1044b24 100644 --- a/tui/context_command_test.go +++ b/tui/context_command_test.go @@ -229,3 +229,88 @@ func TestCompressPreviewRefusedDuringARun(t *testing.T) { t.Fatalf("status = %q", m.statusMsg) } } + +// TestKeepCommands pins the path rule's commands: add, list, drop, and the +// message a user gets for each. +func TestKeepCommands(t *testing.T) { + m := newTestTUI() + + m.handleCommand("/keep internal/store.go") + if !strings.Contains(lastMessage(m), "kept internal/store.go") { + t.Fatalf("reply = %q", lastMessage(m)) + } + if len(m.agent.KeptPaths) != 1 || m.agent.KeptPaths[0] != "internal/store.go" { + t.Fatalf("kept paths = %v", m.agent.KeptPaths) + } + + m.handleCommand("/keep") + if !strings.Contains(lastMessage(m), "internal/store.go") { + t.Fatalf("listing = %q", lastMessage(m)) + } + + m.handleCommand("/keep internal/store.go") + if !strings.Contains(lastMessage(m), "already kept") { + t.Fatalf("duplicate reply = %q", lastMessage(m)) + } + + m.handleCommand("/unkeep internal/store.go") + if !strings.Contains(lastMessage(m), "dropped internal/store.go") { + t.Fatalf("unkeep reply = %q", lastMessage(m)) + } + if len(m.agent.KeptPaths) != 0 { + t.Fatalf("kept paths after unkeep = %v", m.agent.KeptPaths) + } + m.handleCommand("/unkeep internal/store.go") + if !strings.Contains(lastMessage(m), "not kept") { + t.Fatalf("unkeep twice reply = %q", lastMessage(m)) + } +} + +// TestKeepPathSurvivesTheSessionFile pins S4's persistence acceptance: the rule +// is the user's, so it travels with the session and comes back on resume. +func TestKeepPathSurvivesTheSessionFile(t *testing.T) { + m := newTestTUI() + m.SessionDir = t.TempDir() + m.messages = append(m.messages, chatMessage{Role: "user", Content: "hello"}) + m.agent.History = append(m.agent.History, types.Message{Role: types.RoleUser, Content: "hello"}) + + m.handleCommand("/keep internal/store.go") + + saved, err := session.Load(m.currentBranch, m.SessionDir) + if err != nil { + t.Fatalf("load session: %v", err) + } + if len(saved.KeptPaths) != 1 || saved.KeptPaths[0] != "internal/store.go" { + t.Fatalf("the session kept %v", saved.KeptPaths) + } + + // The resume path restores it into the agent. + next := newTestTUI() + next.adoptSession(saved) + if len(next.agent.KeptPaths) != 1 || next.agent.KeptPaths[0] != "internal/store.go" { + t.Fatalf("adoptSession restored %v", next.agent.KeptPaths) + } +} + +// TestKeepRefusedDuringARun pins the guard: the rule is read by the run's +// compaction, so it must not change under one. +func TestKeepRefusedDuringARun(t *testing.T) { + m := newTestTUI() + m.runMu.Lock() + m.runActive = true + m.runMu.Unlock() + t.Cleanup(func() { + m.runMu.Lock() + m.runActive = false + m.runMu.Unlock() + }) + + m.handleCommand("/keep internal/store.go") + + if !strings.Contains(m.statusMsg, "a run is in progress") { + t.Fatalf("status = %q", m.statusMsg) + } + if len(m.agent.KeptPaths) != 0 { + t.Fatalf("the rule changed during a run: %v", m.agent.KeptPaths) + } +} diff --git a/tui/model.go b/tui/model.go index ddbd4f0..bbb5864 100644 --- a/tui/model.go +++ b/tui/model.go @@ -249,6 +249,8 @@ func commandList() []cmdEntry { {"/pin", "Keep the last turn out of compression"}, {"/unpin", "Clear a pin on the last turn"}, {"/drop", "Drop the last turn from this context"}, + {"/keep", "Keep a file's reads out of compaction"}, + {"/unkeep", "Drop a kept path"}, {"/remember", "Store a fact for later sessions"}, {"/memory", "List or forget stored facts"}, {"/sandbox", "Sandbox status"}, @@ -322,6 +324,8 @@ func (m *TuiModel) adoptSession(branch *session.Session) { // The model's copy is rebuilt with the same rule a resume uses: user and // assistant turns only, never command output and never prior reasoning. m.agent.History = historyFromSession(branch.Messages) + // The kept-path rule is the user's, and it survives the file with the turns. + m.agent.KeptPaths = append([]string(nil), branch.KeptPaths...) if m.todoStore != nil { // Replace, never merge: an empty list has to clear the previous // session's items, which is exactly what a merge would keep. diff --git a/tui/testdata/golden/frames/palette_120x40.txt b/tui/testdata/golden/frames/palette_120x40.txt index 5ce0d08..32504cd 100644 --- a/tui/testdata/golden/frames/palette_120x40.txt +++ b/tui/testdata/golden/frames/palette_120x40.txt @@ -50,6 +50,8 @@ /pin Keep the last turn out of compression /unpin Clear a pin on the last turn /drop Drop the last turn from this context + /keep Keep a file's reads out of compaction + /unkeep Drop a kept path /remember Store a fact for later sessions /memory List or forget stored facts /sandbox Sandbox status diff --git a/tui/testdata/golden/frames/palette_30x12.txt b/tui/testdata/golden/frames/palette_30x12.txt index 92f8b11..9ae77fc 100644 --- a/tui/testdata/golden/frames/palette_30x12.txt +++ b/tui/testdata/golden/frames/palette_30x12.txt @@ -35,6 +35,10 @@ /drop Drop the last turn from this context + /keep + Keep a file's reads out of + compaction + /unkeep Drop a kept path /remember Store a fact for later sessions diff --git a/tui/testdata/golden/frames/palette_40x12.txt b/tui/testdata/golden/frames/palette_40x12.txt index a1ce46d..ae1f1bb 100644 --- a/tui/testdata/golden/frames/palette_40x12.txt +++ b/tui/testdata/golden/frames/palette_40x12.txt @@ -28,6 +28,10 @@ /unpin Clear a pin on the last turn /drop Drop the last turn from this context + /keep + Keep a file's reads out of + compaction + /unkeep Drop a kept path /remember Store a fact for later sessions /memory List or forget stored facts diff --git a/tui/testdata/golden/frames/palette_80x24.txt b/tui/testdata/golden/frames/palette_80x24.txt index 06f3442..902e135 100644 --- a/tui/testdata/golden/frames/palette_80x24.txt +++ b/tui/testdata/golden/frames/palette_80x24.txt @@ -34,6 +34,8 @@ /pin Keep the last turn out of compression /unpin Clear a pin on the last turn /drop Drop the last turn from this context + /keep Keep a file's reads out of compaction + /unkeep Drop a kept path /remember Store a fact for later sessions /memory List or forget stored facts /sandbox Sandbox status diff --git a/tui/update.go b/tui/update.go index b715c9a..8fa0d85 100644 --- a/tui/update.go +++ b/tui/update.go @@ -993,6 +993,9 @@ func (m *TuiModel) persistSession() { s = session.New(id, m.SessionDir) } s.Containment = tool.ContainmentInfo().String() + if m.agent != nil { + s.KeptPaths = append([]string(nil), m.agent.KeptPaths...) + } for _, chatMsg := range m.messages { s.Append(types.Message{ Role: chatMsg.Role, @@ -1240,6 +1243,8 @@ Commands: /pin [n] Keep the last n turns out of compression (default 1) /unpin [n] Clear the pin on the last n pinned turns /drop [n] Drop the last n turns from this context (default 1) + /keep Keep a file's reads out of run compaction (/keep alone lists) + /unkeep Drop a kept path /model Switch provider/model Keyboard: @@ -1472,7 +1477,7 @@ Mouse: // Creating or resuming a goal while the session is idle starts working // on it after the grace period; pausing or clearing schedules nothing. return m, m.scheduleGoalRound() - case "/pin", "/unpin", "/drop": + case "/pin", "/unpin", "/drop", "/keep", "/unkeep": return m.handleContextCommand(cmd, base) case "/remember": m.handleRemember(strings.TrimSpace(strings.TrimPrefix(cmd, "/remember"))) From 4e43d21ec6f28faab57c579aec67d5dfa8daba46 Mon Sep 17 00:00:00 2001 From: Siwen Yu Date: Sat, 31 Oct 2026 22:33:38 +0800 Subject: [PATCH 2/2] test(tui): wait for the palette's first entry, not its heading MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The palette grew by two entries with S4's commands, and at 80x24 the "Commands:" heading now scrolls off the list before the list scrolls. The scenario's first wait was on that heading, so it timed out with the palette correctly open. It waits on "Show help" — the first entry — instead: that is what "the palette is open" means, and it does not depend on the list fitting one screen. Run locally through tuiprobe: 13 steps, exit 0. Refs #314 --- tui/testdata/scenarios/palette.scenario | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tui/testdata/scenarios/palette.scenario b/tui/testdata/scenarios/palette.scenario index c1ba361..d70f6fc 100644 --- a/tui/testdata/scenarios/palette.scenario +++ b/tui/testdata/scenarios/palette.scenario @@ -8,7 +8,10 @@ open --dir . --size 80x24 --env HOME=/tmp/tinyscen-home --env TERM=xterm-256colo wait --text "Type your request" --timeout 20s wait --text TinyCode --timeout 30s send --text / -wait --text Commands: --timeout 10s +# Wait for the first entry, not the "Commands:" heading: the palette grows, and at +# 80x24 the heading scrolls off before the list does. The first entry is what +# "the palette is open" means here. +wait --text "Show help" --timeout 10s send --text theme wait --text theme --timeout 10s screenshot /tmp/tinyscen-shots/palette-80x24.png --scale 2