From 4a572abf4448a5dfe4b44f605602f0d5f7483f46 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sat, 12 Sep 2026 10:19:14 -0400 Subject: [PATCH 1/8] chrome: the wire model is the engine's own fact, and every cell beside it reads it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit THE ONE RULE: THE CHROME NAMES THE MODEL THE ENGINE IS ON. `a.model` is THE DIAL — what the person last picked, what the next turn will use. What the engine is talking to right now is a different fact, and the two part company the moment somebody picks a model while work is running. On 2026-09-11 a person read both answers side by side for seventeen minutes: the model cell drawn from the dial, the `via` machine drawn from the request in flight, neither cell wrong on its own terms. The engine already held the second fact as `a.riding` (steer.go). This publishes it — [Agent.TurnModel], "" when nothing is in flight — and moves every cell drawn beside the model to it: the seam's model word, the waiting line, both `via` sightings, the lane rider, /status's model and lane rows, the @lane pin, and the press targets behind the chip, so what lights is what the press acts on. Over --host it crosses as its own fact on the photograph the thinking rung already rides (session.Facts.TurnModel, remote.Welcome.TurnModel, wire 16 → 17); against an older engine the seam draws the dial alone rather than guessing. AND THE RUNG RESOLVES FOR THE MODEL THE CELL NAMES ([Agent.ResolvedEffortFor]). Reasoning levels live per model id, so the old cell drew one model's name beside another model's level, and a rescue's hop moved the turn's rung without moving the drawn one. AND THE PICTURES ARE MADE SAFE BY THE MOVE, NOT BY THE PICKER ([Agent.ridesOnLocked]). The scrub used to run in SetModel, which is a preference that may never be acted on — and under steer.go's law a pick can reach the request in flight, which the picker's scrub misses by one request. It now rides the one door the model the work is talking to changes through, and only when it actually changed: `Config.SeesImages` answers a tri-state, so sending needs a positive `sees` and scrubbing a positive `blind`, and an oracle that changes its mind about one id can no longer eat a screenshot nobody swapped away from. Co-Authored-By: Claude Opus 5 (1M context) --- cmd/aforge/chatv3.go | 30 ++- cmd/aforge/chatv3_images_test.go | 57 +++-- cmd/aforge/chatv3_modelshelf_test.go | 4 +- .../993-chrome-names-the-wire-model.md | 28 +++ internal/manual/chat/commands.md | 5 +- internal/manual/chat/keys.md | 16 +- internal/manual/chat/models-and-cost.md | 16 +- .../manual/chat/running-on-another-machine.md | 4 +- internal/manual/chat/screen.md | 77 ++++++- internal/remote/effort.go | 20 ++ internal/remote/server.go | 6 +- internal/remote/turnmodel.go | 48 ++++ internal/remote/wire.go | 29 ++- internal/session/agent.go | 72 +++++- internal/session/blindswap.go | 57 ++++- internal/session/blindswap_test.go | 14 +- internal/session/effort.go | 23 +- internal/session/facts.go | 32 ++- internal/session/image.go | 17 +- internal/session/image_test.go | 20 +- internal/session/loop.go | 17 ++ internal/session/orchestrate.go | 2 +- internal/session/phasenews.go | 28 ++- internal/session/replay_test.go | 2 +- internal/session/session.go | 49 +++- internal/session/steer.go | 33 ++- internal/session/task_run.go | 4 +- internal/session/tools_doc.go | 2 +- internal/session/tools_doc_test.go | 6 +- internal/session/tools_image_test.go | 8 +- internal/session/turnmodel_law_test.go | 126 +++++++++++ internal/session/turnmodel_test.go | 205 +++++++++++++++++ internal/session/vision_slot_test.go | 4 +- internal/tui3/app.go | 8 +- internal/tui3/effortchip.go | 28 ++- internal/tui3/effortchip_test.go | 41 ++-- internal/tui3/effortscope.go | 35 ++- internal/tui3/foot.go | 2 +- internal/tui3/imagepaste_test.go | 12 +- internal/tui3/lanes.go | 129 ++++++++++- internal/tui3/palette.go | 39 +++- internal/tui3/render.go | 20 +- internal/tui3/statusdeck.go | 15 +- internal/tui3/tui3_test.go | 32 ++- internal/tui3/wiremodel_law_test.go | 211 ++++++++++++++++++ internal/tui3/wiremodel_test.go | 163 ++++++++++++++ 46 files changed, 1605 insertions(+), 191 deletions(-) create mode 100644 docs/changes/unreleased/993-chrome-names-the-wire-model.md create mode 100644 internal/remote/turnmodel.go create mode 100644 internal/session/turnmodel_law_test.go create mode 100644 internal/session/turnmodel_test.go create mode 100644 internal/tui3/wiremodel_law_test.go create mode 100644 internal/tui3/wiremodel_test.go diff --git a/cmd/aforge/chatv3.go b/cmd/aforge/chatv3.go index 13b6cdbf25..26a3127a31 100644 --- a/cmd/aforge/chatv3.go +++ b/cmd/aforge/chatv3.go @@ -893,7 +893,7 @@ func openV3Launch(proc *v3Process, opts v3Options) (*v3Launch, error) { // session's model changes under /model (see [v3SeesImages]). It reads // the SHELF, so a model picked out of a list somebody refreshed a moment // ago is answered from that list rather than refused as unknown. - SupportsImages: v3SeesImages(proc.Shelf), + SeesImages: v3SeesImages(proc.Shelf), // The published answer to "may this call carry this knob", which the // adapter asks before it lets an optional field travel. It was wired to // nothing on this path, so a reasoning level set with ctrl+t or @@ -2117,34 +2117,44 @@ func v3AnswersText(outputs []string) bool { // The file is read at most once per session: it is the same rows for the whole // warming window, and re-reading it per message would put I/O on the message // path to learn nothing new. -func v3SeesImages(models v3Catalog) func(string) bool { +// AND IT ANSWERS IN THREE STATES, NOT TWO ([session.ModelSight]). A row this +// door has actually read is a `sees` or a `blind`; a catalog that has not +// answered yet and a disk cache that does not carry the id are `unknown`, and +// the difference decides whether a person's screenshot survives. Until the third +// state existed a cold catalog said `blind` in the same breath a warm one said +// `sees`, for one id, one conversation, a minute apart — and the scrub that read +// it took the pictures out of the transcript for good (internal/session's +// blindswap.go states what each caller does with which answer). +func v3SeesImages(models v3Catalog) func(string) session.ModelSight { var once sync.Once var cached []tui3.Model - return func(model string) bool { + return func(model string) session.ModelSight { model = strings.TrimSpace(model) if model == "" { - return false + return session.SightUnknown } if models != nil { if rows := models.ModelsNow(); len(rows) > 0 { for _, row := range rows { if strings.EqualFold(strings.TrimSpace(row.ID), model) { - return v3ReadsImages(row.InputModalities) + return session.SightOf(v3ReadsImages(row.InputModalities)) } } // The catalog HAS answered and does not carry this id — a - // hand-typed slug, a model this router never listed. That is a - // no on the same terms as an unpublished modality. - return false + // hand-typed slug, a model this router never listed. Nothing has + // been READ about this model, so nothing is known about it: it is + // refused a picture like any unknown, and its pictures are left + // where they are like any unknown. + return session.SightUnknown } } once.Do(func() { cached = tui3.CachedModels() }) for _, row := range cached { if strings.EqualFold(strings.TrimSpace(row.ID), model) { - return v3ReadsImages(row.Input) + return session.SightOf(v3ReadsImages(row.Input)) } } - return false + return session.SightUnknown } } diff --git a/cmd/aforge/chatv3_images_test.go b/cmd/aforge/chatv3_images_test.go index 7c3285c4ab..36514ce8fd 100644 --- a/cmd/aforge/chatv3_images_test.go +++ b/cmd/aforge/chatv3_images_test.go @@ -25,29 +25,38 @@ func TestTheVisionGateAnswersFromTheCatalogsInputModalities(t *testing.T) { {ID: "vendor/quiet", OutputModalities: []string{"text"}}, }} - cfg := session.Config{Model: "vendor/sees", SupportsImages: v3SeesImages(models)} - if !cfg.SupportsImages("vendor/sees") { - t.Fatal("a model that publishes image input was gated out") + cfg := session.Config{Model: "vendor/sees", SeesImages: v3SeesImages(models)} + if got := cfg.SeesImages("vendor/sees"); got != session.SightSees { + t.Fatalf("a model that publishes image input reads as %v", got) } // Case is not a difference: it is the same id either way. - if !cfg.SupportsImages("Vendor/Sees") { - t.Fatal("the gate answered case rather than identity") + if got := cfg.SeesImages("Vendor/Sees"); got != session.SightSees { + t.Fatalf("the gate answered case rather than identity: %v", got) } - for _, model := range []string{"vendor/reads", "vendor/quiet", "vendor/unheard-of", ""} { - if cfg.SupportsImages(model) { - t.Fatalf("%q was told it can read images", model) + // A ROW THIS DOOR HAS READ IS A VERDICT; AN ID IT HAS NOT IS IGNORANCE. The + // two were one answer until 2026-09-12, and the scrub that read them took a + // person's pictures out of the transcript on the strength of the second + // (internal/session's blindswap.go). + for _, model := range []string{"vendor/reads", "vendor/quiet"} { + if got := cfg.SeesImages(model); got != session.SightBlind { + t.Fatalf("%q, whose row this door has read, answers %v", model, got) + } + } + for _, model := range []string{"vendor/unheard-of", ""} { + if got := cfg.SeesImages(model); got != session.SightUnknown { + t.Fatalf("%q, which this door has never read, answers %v rather than ignorance", model, got) } } - // A catalog still warming, with no cache on disk either, answers no — and - // answers it per call, so the same session says yes once the rows land - // rather than being pinned at boot. + // A catalog still warming, with no cache on disk either, knows NOTHING — and + // answers per call, so the same session says yes once the rows land rather + // than being pinned at boot. warming := fakeV3Catalog{} - if v3SeesImages(warming)("vendor/sees") { - t.Fatal("a warming catalog vouched for a model it has not read") + if got := v3SeesImages(warming)("vendor/sees"); got != session.SightUnknown { + t.Fatalf("a warming catalog answered %v for a model it has not read", got) } - if v3SeesImages(nil)("vendor/sees") { - t.Fatal("no catalog at all vouched for a model") + if got := v3SeesImages(nil)("vendor/sees"); got != session.SightUnknown { + t.Fatalf("no catalog at all answered %v for a model", got) } } @@ -69,14 +78,18 @@ func TestTheVisionGateReadsTheDiskCacheWhileTheCatalogIsWarming(t *testing.T) { } gate := v3SeesImages(fakeV3Catalog{}) - if !gate("vendor/sees") { - t.Fatal("the cache's own witness that a model can see was ignored") + if got := gate("vendor/sees"); got != session.SightSees { + t.Fatalf("the cache's own witness that a model can see reads as %v", got) } - for _, model := range []string{"vendor/reads", "vendor/painter", "vendor/unheard-of"} { - if gate(model) { - t.Fatalf("%q was told it can read images off the cache", model) + for _, model := range []string{"vendor/reads", "vendor/painter"} { + if got := gate(model); got != session.SightBlind { + t.Fatalf("%q, whose cached row this door has read, answers %v", model, got) } } + // AND A MODEL THE CACHE HAS NEVER HEARD OF IS IGNORANCE, not a verdict. + if got := gate("vendor/unheard-of"); got != session.SightUnknown { + t.Fatalf("a model the cache does not carry answers %v rather than ignorance", got) + } // AND A CATALOG THAT HAS ANSWERED WINS. The cache is the warming rung and // never a second opinion: a row the live catalog carries is answered from @@ -84,8 +97,8 @@ func TestTheVisionGateReadsTheDiskCacheWhileTheCatalogIsWarming(t *testing.T) { live := v3SeesImages(fakeV3Catalog{rows: []catalog.Model{ {ID: "vendor/sees", InputModalities: []string{"text"}, OutputModalities: []string{"text"}}, }}) - if live("vendor/sees") { - t.Fatal("a stale cache overruled the catalog that had already answered") + if got := live("vendor/sees"); got != session.SightBlind { + t.Fatalf("a stale cache overruled the catalog that had already answered: %v", got) } } diff --git a/cmd/aforge/chatv3_modelshelf_test.go b/cmd/aforge/chatv3_modelshelf_test.go index eeeb016cfe..f5ee91a754 100644 --- a/cmd/aforge/chatv3_modelshelf_test.go +++ b/cmd/aforge/chatv3_modelshelf_test.go @@ -189,8 +189,8 @@ func TestTheShelfTakesTodaysListAndKeepsYesterdaysOnFailure(t *testing.T) { if err != nil || len(rows) != 2 || at.IsZero() { t.Fatalf("a landed refresh answered %d rows at %v, error %v", len(rows), at, err) } - if !v3SeesImages(live)("vendor/shipped-this-morning") { - t.Fatal("the vision gate does not know the model the refresh brought") + if got := v3SeesImages(live)("vendor/shipped-this-morning"); got != session.SightSees { + t.Fatalf("the vision gate reads the model the refresh brought as %v", got) } cached := tui3.CachedModels() if len(cached) != 2 || cached[1].ID != "vendor/shipped-this-morning" { diff --git a/docs/changes/unreleased/993-chrome-names-the-wire-model.md b/docs/changes/unreleased/993-chrome-names-the-wire-model.md new file mode 100644 index 0000000000..f58a3c5200 --- /dev/null +++ b/docs/changes/unreleased/993-chrome-names-the-wire-model.md @@ -0,0 +1,28 @@ +--- +kind: fixed +title: the model on the seam is the model the engine is on, and the rung beside it says which ladder it is +pr: 993 +surface: [chat, engine] +invalidates: + - "The foot's model cell showed the model you last picked. No longer true: while a turn is running it names the model that turn is actually on, and only an idle conversation shows the dial. The door is `internal/tui3.app.wireModel`, which reads `session.Agent.TurnModel()` — the engine's own latch, asked directly and NEVER inferred from a news-desk entry, because a phase entry is deleted at the end of every request and every tool batch and a reading taken from its presence went empty between two steps of one turn. `TestTheSeamNamesTheModelTheTurnIsOnAndNotTheDial` is the law." + - "Only the seam's model cell read the model in flight. No longer true: every cell drawn beside it does — the `via` rider and its sighting lookups, the `waiting for ` word, the `@lane` pin, the `/status` model and lane rows (one reading now, not two), and the press targets behind the chip (`/model`, `/lane`, the picker's fold). `internal/tui3/wiremodel_law_test.go` is now a package-wide ratchet: every function that reads `a.model` is named in `dialReaders` with the reason it means the dial, and a new reader that is not named fails the build." + - "Over `--host` the surface had no way to know the model a far turn was on. No longer true: `session.Facts.TurnModel` crosses on the photograph the thinking rung already rides, `remote.Welcome.TurnModel` states whether the far engine keeps the fact, and the wire version is 17. Against a version-16 engine the seam draws the dial alone and `/model` says nothing about a turn in flight — a capability that cannot work is absent, not guessed." + - "The thinking rung on the seam was resolved for the dial. No longer true: it resolves for the model the cell names (`session.Agent.ResolvedEffortFor`, mirrored on `remote.Agent` and carried in `Facts.Thinking`). Reasoning levels live per model id, so a rung resolved for the dial drew one model's name beside another model's level, and a fallback hop moved the turn's rung without moving the drawn one." + - "`session.Config.SupportsImages func(string) bool` is gone, replaced by `SeesImages func(string) ModelSight` with three answers — `SightUnknown`, `SightBlind`, `SightSees`. Sending pictures needs a positive `sees`; taking them out of a live transcript needs a positive `blind`. As one bool the two shared the reading 'unknown counts as blind', which let a failed catalog fetch strip a conversation's pictures for good." + - "The blind-image scrub ran at the start of every turn. No longer true: it runs only when the model CHANGED since the last turn (`Agent.scrubbedFor`), so an oracle that answers differently twice for one id cannot reach back into a transcript nobody swapped. `TestAFlappingOracleCannotScrubAConversationThatNeverSwapped` and `TestASwapOntoAnUnknownModelKeepsThePictures` are the laws." + - "A vision turn sealed itself with `Agent.Model()`, the dial, while the looking model had run it. No longer true: the seal takes the seer, which is the model named in the reply's own `[vision: ]` prefix. `TestTheWorkInFlightIsSealedUnderItsOwnModel` is the law." + - "`session.Agent`'s phase news carried `a.model`, the dial. No longer true: every stage a turn posts — running, checking, tidying, taking stock — is stamped with the turn's latched model (`Agent.newsModel`), and a mid-turn `/model` changes none of them. The latch moves only when the turn itself hops to a fallback model, beside the `switching model` word that announces it." + - "`/model` mid-turn said nothing about the turn in flight, only about running tasks. No longer true: the note reads `model · — this turn finishes on `, derived from what the engine reports it is on, never from a surface flag." + - "`SetModel` scrubbed a blind model's pictures out of the live transcript immediately, under a running turn that was still sending them to a model that can see. No longer true: the scrub happens where the swap lands, at `Agent.startTurnLocked`, so the turn in flight keeps its images and the next turn starts clean." + - "`auto` beside the model meant the thinking rung, with nothing on the cell saying so — and `auto` is also the lane dial's word (`/model auto`, the `lane` row in `/settings`), which is why it was read as \"lane: auto\" twice. Now spelled `⠿ thinking auto`: the cell names its own ladder at every rung (`⠿ thinking high`, `~ thinking low` on a plain terminal), not only at the word that collided." + - "`runTurn` latched the turn's model by reading the dial again on its own side of the goroutine hand-off. No longer true: the latch is stamped under the same lock that binds the turn's client (`Agent.startTurnLocked`) and read back with `Agent.TurnModel()`, so a pick that lands between the two can no longer leave a turn talking to one model through a client built for another." +--- +One rule closes all of it: THE CHROME NAMES THE MODEL THE ENGINE IS ON. A +person sent a message at 22:48:15, opened the picker at 22:48:18 and chose +kimi-k3 at 22:48:21; the turn had latched glm-5.3-flash and ran there for +seventeen minutes and forty-one calls, which is exactly what `SetModel` +promises. The seam read `moonshotai/kimi-k3 · auto · via wafer` throughout — +the model cell from the picker's answer, the `via` cell from the endpoint of +the request in flight — and neither cell was wrong on its own terms. There is +one reading behind both cells now, and no second road: the model the turn is on +travels on the phase news that already crosses a `--host` connection. diff --git a/internal/manual/chat/commands.md b/internal/manual/chat/commands.md index 7dbb64739c..f12c653c53 100644 --- a/internal/manual/chat/commands.md +++ b/internal/manual/chat/commands.md @@ -884,8 +884,9 @@ model, `enter choose · ← back · esc` inside its lanes. Choosing a model sets it on the agent, teaches the surface its context window and tells the session — compaction fires at a fraction of that window, so this is not decoration — -notes `model · `, and writes the choice into your profile, so the next `aforge` -opens on it. Over `--host` the switch takes for the session and is not written down: the +notes `model · ` — and, when a turn is still running, `model · — this turn +finishes on ` — and writes the choice into your profile, so the +next `aforge` opens on it. Over `--host` the switch takes for the session and is not written down: the model a remote session opens on is that machine's to resolve. ## What the model picker lists, and what it will not do diff --git a/internal/manual/chat/keys.md b/internal/manual/chat/keys.md index 7a06fa7a83..693c83320f 100644 --- a/internal/manual/chat/keys.md +++ b/internal/manual/chat/keys.md @@ -1149,7 +1149,7 @@ The line above the message box — the legend — names how hard the model will your next turn, immediately after the model that will be doing the thinking: ``` -─ porting the parser · glm-5.3-flash · ⠿ high · via deepinfra · main* ──── / commands ─ +─ porting the parser · glm-5.3-flash · ⠿ thinking high · via deepinfra · main* ── / commands ─ › what changed in the relay this week ``` @@ -1159,7 +1159,7 @@ chose: it is the rung the next turn will ask for, whichever setting decided it. *Making the model think harder, deeper, or less* on the "Models and cost" page for the whole ladder and for what each rung asks the provider for. -**A conversation nobody has dialled reads `⠿ auto`**, which is what a shipped install +**A conversation nobody has dialled reads `⠿ thinking auto`**, which is what a shipped install says on every fresh conversation. See *What `auto` means beside the model* below. **`ctrl+v` walks it.** Each press moves one rung up and wraps off the top: @@ -1206,7 +1206,7 @@ What it changes and what it does not: row in `/settings`, which ships at `auto` (the provider default). - **`auto` is on the legend and it is the ladder's top row.** With thinking at `auto` and no more specific level chosen — which is what a shipped install is — the cell reads - `⠿ auto`, it is pressable, and `ctrl+v` walks it onto `low`. + `⠿ thinking auto`, it is pressable, and `ctrl+v` walks it onto `low`. - **It works on a `--host` conversation.** The rung is set on the engine machine, where the conversation lives, and the word on your legend is the one that machine resolved. An engine too old to know the ladder says so at the door and there is then no rung on @@ -1224,17 +1224,19 @@ without looking away from what you are typing, and then it goes quiet again. Set back to `auto` flashes the same way and writes one line: *thinking · auto · the model decides*. -## What `auto` means beside the model — putting thinking back to auto, and why the cell is there at all +## What `thinking auto` means beside the model — putting thinking back to auto, why it does not say a lane, and why the cell is there at all -`⠿ auto` on the line above the message box means **nobody has asked this conversation to -think any particular amount**. aforge sends no reasoning field on the request at all, and +`⠿ thinking auto` on the line above the message box means **nobody has asked this +conversation to think any particular amount**. The cell names its ladder — `thinking` — +because `auto` on its own is also the **lane** dial's word (`/model auto`, the `lane` row +in `/settings`), and the bare word was read as "lane: auto" twice. aforge sends no reasoning field on the request at all, and the model thinks however it thinks — its own published default. It is not "think as little as possible": that is a different request, and `low` is the rung for it. **It is what a fresh install says.** The **thinking** row in `/settings` ships at `auto`, so until you dial something — this conversation with `ctrl+v`, `/effort` or a press on the cell; one model with the picker's `ctrl+t`; one task with `ctrl+v` on it; or the machine -itself in `/settings` — every conversation reads `⠿ auto`. +itself in `/settings` — every conversation reads `⠿ thinking auto`. **To put it back to `auto`:** type `/effort auto` (or `/effort off`, the older name for the same thing), or open `/effort` and pick the top row. **`ctrl+v` and pressing the cell diff --git a/internal/manual/chat/models-and-cost.md b/internal/manual/chat/models-and-cost.md index f96a1dda10..d78b28b975 100644 --- a/internal/manual/chat/models-and-cost.md +++ b/internal/manual/chat/models-and-cost.md @@ -72,7 +72,10 @@ were reading. Choosing a model does four things: the model is set on the session, the machine running the session learns that model's context window for compaction, a note appears reading -`model · `, and the choice is written into your profile. +`model · `, and the choice is written into your profile. **If a turn is still +running when you pick, the note says what the switch did not touch** — +`model · moonshotai/kimi-k3 — this turn finishes on zhipu/glm-5.3-flash` — because a turn +finishes on the model it started on. Over `--host`, the picker and its prices are this laptop's catalog, while the context window used for compaction comes from the far machine's catalog. The machine doing the @@ -964,8 +967,9 @@ use, and all five rungs are reachable here. How hard the model thinks is one dial with five rungs, cheapest first: `low`, `medium`, `high`, `xhigh`, `max`. There is also **auto**, which is the dial left alone — aforge asks for nothing and the model thinks however it thinks. Auto is the **shipped** setting, and -`⠿ auto` is what the line above the message box reads until something is dialled; no rung -is the shipped one. +`⠿ thinking auto` is what the line above the message box reads until something is dialled; +no rung is the shipped one. That cell names its ladder — `thinking` — because `auto` on its +own is also what the **lane** dial says, and the bare word was read as "lane: auto" twice. **The default is `auto`.** It is the **thinking** row in `/settings`, among the model rows beside the model you talk to, and its choices are `auto, low, medium, high, xhigh, max` — @@ -991,7 +995,7 @@ Several things can name a rung, and the most specific one wins: **`ctrl+v` moves the rung of whatever you are standing on.** In the message box it moves **this conversation's** rung, which is named on the line above the box, beside the model: -`glm-5.3-flash · ⠿ high`. On a task — the roster row under the cursor, or the page you are +`glm-5.3-flash · ⠿ thinking high`. On a task — the roster row under the cursor, or the page you are inside — it moves that task's rung. On a standing item's card it moves that item's. A conversation's rung and an item's rung climb one step each press and wrap from `max` back to `low`; they never go back to "nobody said". For this conversation, `/effort auto` and @@ -1017,8 +1021,8 @@ A word that is none of the six changes nothing and prints them all. This is conversation hands out: a task worker starts at it. **It works over `--host` too.** The rung is set on the machine the conversation is running -on and the word on your line is the one that machine resolved — `⠿ auto` included, on a -hosted conversation nobody has dialled. Against an engine too old to know the ladder there +on and the word on your line is the one that machine resolved — `⠿ thinking auto` +included, on a hosted conversation nobody has dialled. Against an engine too old to know the ladder there is no rung on the line and neither the chord nor `/effort` offers one — a capability that cannot work is absent rather than broken. diff --git a/internal/manual/chat/running-on-another-machine.md b/internal/manual/chat/running-on-another-machine.md index b191adc789..ebfe4cf3ad 100644 --- a/internal/manual/chat/running-on-another-machine.md +++ b/internal/manual/chat/running-on-another-machine.md @@ -375,10 +375,10 @@ moment the link does. ## Make a hosted conversation think harder — ctrl+v, /effort and the thinking rung over --host **It works, and the rung is set on the machine the conversation is running on.** The line -above your message box names it beside the model — `glm-5.3-flash · ⠿ high` — and all +above your message box names it beside the model — `glm-5.3-flash · ⠿ thinking high` — and all three doors reach across: `ctrl+v` walks it a step, pressing it walks it a step, and `/effort` opens the six rows or takes one outright (`/effort max`). A hosted conversation -nobody has dialled reads `⠿ auto`, exactly as a local one does, and `/effort auto` clears +nobody has dialled reads `⠿ thinking auto`, exactly as a local one does, and `/effort auto` clears it back there. The word you see is the rung **that machine** resolved, not the one this one would have diff --git a/internal/manual/chat/screen.md b/internal/manual/chat/screen.md index f94ef937fb..122d40dfa2 100644 --- a/internal/manual/chat/screen.md +++ b/internal/manual/chat/screen.md @@ -653,15 +653,16 @@ talking to and where** on the left, and the keys that work now on the right, lik legend on a fieldset: ``` -─ porting the parser · glm-5.3-flash · ⠿ high · via deepinfra · main* ──── space space home · / commands ─ +─ porting the parser · glm-5.3-flash · ⠿ thinking high · via deepinfra · main* ── space space home · / commands ─ ``` The left, in order: the machine on a `--host` session (`devbox · …`), the conversation's **name** (the one the session chose for itself, falling back to the folder's name until it has named itself, so it is never empty), the **model** as its basename -(`glm-5.3-flash`), the **thinking rung** this conversation is running at (`⠿ high`, `~ -high` on a plain terminal — `⠿ auto` until something is dialled, which is what a fresh -install says), a `· via deepinfra` rider naming the endpoint that answered (the name alone, never a timing: the last answer's wait and speed are the `served` row of `/status`), +(`glm-5.3-flash`), the **thinking rung** this conversation is running at (`⠿ thinking +high`, `~ thinking high` on a plain terminal — `⠿ thinking auto` until something is +dialled, which is what a fresh install says), a `· via deepinfra` rider naming the +endpoint that answered (the name alone, never a timing: the last answer's wait and speed are the `served` row of `/status`), and the git **branch** with a `*` when the tree has uncommitted work. In a directory that is not a repository there is no branch. It never says "untitled" and never invents a placeholder. @@ -680,6 +681,14 @@ Until 2026-09-09 the rung was spelled onto the model id with a colon row. That was one ladder said two ways on one screen; there is one cell now, and it names the rung that will **actually** be asked for whichever setting decided it. +**The rung cell says the word `thinking` before the rung.** It used to read `⠿ auto` +bare, and `auto` is also what the **lane** dial says — `/model auto` hands the choice of +machine back to aforge, and the `lane` row in `/settings` reads `auto` too. So +`glm-5.3-flash · ⠿ auto` had two honest readings and was taken for "lane: auto" twice. +The cell names its own ladder at every rung now, not only at `auto`: `⠿ thinking auto`, +`⠿ thinking low`, `⠿ thinking high`. Which machine you are pinned to is written on the +model's own word instead, as `glm-5.3-flash@cloudflare`. + The rate the endpoint is writing at is *not* on this line — while a turn runs it stands beside the state word on the status row (`38 tok/s · ⠹ working · 12s`), because how fast is a claim about now and who is attribution. @@ -797,6 +806,66 @@ legend it answers to a press: click it and you are back in the conversation. Cli page itself does not leave a room — a press on empty space does nothing here as it does everywhere. +## Which model is actually answering — I changed the model mid-answer and the line still shows the old one + +**The model on that line is the model the answer in flight is being written by, not the +one you last picked.** While a turn is running the cell names what the engine is actually +talking to; when nothing is running it names what the next message will use, which is +what you picked. + +That is the same reading the `· via deepinfra` rider comes from, so the two cells can +never disagree. Before 2026-09-12 they could: the model cell was drawn from your last +pick and the rider from the request in flight, so a person who picked `kimi-k3` three +seconds into a turn read `moonshotai/kimi-k3 · via wafer` for the next seventeen minutes +while every one of the forty-one calls went to `glm-5.3-flash`. + +**Over `--host` this needs an engine that keeps the fact.** The far machine states at +the door whether it can name the model a turn is on; against an older engine the line +names the model you picked, exactly as it did before, and `/model` says nothing about a +turn in flight rather than guessing. + +**Changing the model does not move the answer you are waiting for.** A turn finishes on +the model it started on — every step and every retry of it — and your pick takes effect +at the next message you send. `/model` says so at the moment you press it: + +``` +model · moonshotai/kimi-k3 — this turn finishes on zhipu/glm-5.3-flash +``` + +If you want the new model *now*, stop the turn with `esc` and send the message again; +`ctrl+q` queues it to run after this one finishes, on the new model. + +**Tasks already running keep their own model too**, and `/model` says that in the same +line when any are running. A task's model is frozen when the task is admitted; tasks +started after the switch follow it. + +**Pictures you attached stay with the turn that is using them.** Switching to a model +that cannot see images replaces them with a line naming the file — but only from the +next turn, never under a turn that is still sending them to a model that can see. + +**And only a switch does that.** Turns of a conversation that never changed model leave +the pictures alone, and so does a switch to a model aforge has read no row for: replacing +a picture cannot be undone, so it happens only when the model changed *and* the new one is +known to have no vision. A model nothing is known about is refused a picture — it goes to +the looking model instead — but it never costs you the one you already attached. + +## The cells on that line, one by one + +Left to right, each cell and what question it answers: + +| cell | what it is | +| --- | --- | +| `devbox ·` | the machine, on a `--host` session only | +| `porting the parser` | this conversation's name, or the folder's name until it has one | +| `glm-5.3-flash` | the model the engine is on right now — the dial when nothing is running | +| `@cloudflare` | on the model's own word: the machine this model's requests are pinned to. Absent on `auto` | +| `⠿ thinking high` | how hard this conversation is asked to think. `thinking auto` means nothing is asked for and the model decides | +| `via deepinfra` | the endpoint that is writing, or wrote last. A name only, never a timing | +| `main*` | the git branch, with `*` for uncommitted work | + +The speed is not here: `38 tok/s` stands beside the state word on the status row at the +bottom, because how fast is a claim about now and who served is attribution. + ## Why is one word in a line brighter than the rest — highlighted model names, keys and figures Every line aforge writes about itself — a note in the conversation, the hint slot on the diff --git a/internal/remote/effort.go b/internal/remote/effort.go index 42b6168dab..c5232f4ffb 100644 --- a/internal/remote/effort.go +++ b/internal/remote/effort.go @@ -36,6 +36,13 @@ type effortDoor interface { SetConversationEffort(rung string) bool } +// resolvedEffortForDoor is the per-model half of the dial, asserted separately +// so an engine that predates it still advertises a dial ([effortKnown]) and +// still answers for the conversation as a whole. +type resolvedEffortForDoor interface { + ResolvedEffortFor(model string) string +} + // effortKnown is whether this engine has the whole dial. A partial one is no // dial at all: the surface draws the resolved rung and moves the stored one, so // an engine with one of the three would light a cell nothing could turn. @@ -53,6 +60,19 @@ func (a *Agent) EffortSupported() bool { return a.c.Welcome().Effort } // reason). func (a *Agent) ResolvedEffort() string { return a.c.facts.read().Thinking } +// ResolvedEffortFor is that rung for one model id — and over a connection it is +// the SAME memory read, whatever id is asked for. +// +// THE ENGINE HAS ALREADY RESOLVED IT FOR THE MODEL IT IS ON. [session.Facts] +// carries the rung resolved for [session.Facts.TurnModel] (its facts.go states +// why), and the model this surface names is that same model +// (internal/tui3's [app.wireModel]) — so the id a caller passes is the id the +// answer was resolved for, and asking the far machine again would be a call on +// a frame, which PERF.md forbids. An id that is NOT the one the engine is on has +// no answer here and gets the engine's, which is the honest fallback: this end +// keeps no ladder of its own to resolve with. +func (a *Agent) ResolvedEffortFor(string) string { return a.ResolvedEffort() } + // ConversationEffort is the rung THIS conversation was set to, "" when nobody // has set one. // diff --git a/internal/remote/server.go b/internal/remote/server.go index 3ee4152687..ed8c203a45 100644 --- a/internal/remote/server.go +++ b/internal/remote/server.go @@ -1143,7 +1143,11 @@ func (sess *Session) welcomeLocked(s *server) Welcome { // asked of the agent it has open — for [Welcome.Effort]'s stated reason: // neither a type assertion at the far end nor the rung itself can tell an // engine without a dial from a conversation whose dial is off. - Effort: effortKnown(sess.agent), + Effort: effortKnown(sess.agent), + // And whether it can name the model the turn in flight is on, asked the + // same way and for the same reason: "" is a real answer here, so only the + // door can tell an idle conversation from an engine with no latch. + TurnModel: turnModelKnown(sess.agent), TaskSettle: taskSettleKnown(sess.agent), // Whether this conversation's news reaches the surface at all, asked the // way the newsroom files it ([Session.fileNews]): an engine that cannot diff --git a/internal/remote/turnmodel.go b/internal/remote/turnmodel.go new file mode 100644 index 0000000000..e348142c76 --- /dev/null +++ b/internal/remote/turnmodel.go @@ -0,0 +1,48 @@ +package remote + +// ── THE MODEL THE TURN IN FLIGHT IS ON, ACROSS THE WIRE ───────────────────── +// +// internal/session latches a turn's model when the turn opens and moves it only +// when the turn itself hops to a fallback ([session.Agent.TurnModel]). The +// surface names that model wherever it names this conversation's model, so the +// cell and the `via` machine beside it cannot disagree (internal/tui3's +// [app.wireModel]) — and over a connection the fact has to cross. +// +// IT RIDES THE PHOTOGRAPH AND IS NEVER A CALL. The seam draws it on every +// repaint, and PERF.md's law is that a View over --host issues zero far calls, +// so it arrives on the fact set the engine states unasked ([session.Facts], +// replica.go) exactly as the thinking rung beside it does. +// +// AND THE CAPABILITY IS THE WELCOME'S TO ANSWER, not the type system's. Every +// connection has the method on it, and "" is a real answer — an idle +// conversation — so neither the assertion nor the answer can tell an engine that +// has no latch from a conversation that is simply not working. +// [Welcome.TurnModel] is the only thing that can. + +// turnModelDoor is the latch as an engine must have it to serve one. +// +// It is asserted rather than required for [effortDoor]'s reason — a capability +// is asserted, never required — so an engine built before the latch keeps its +// conversations and simply advertises nothing. +type turnModelDoor interface { + TurnModel() string +} + +// turnModelKnown is whether this engine can name the model a turn is on. +func turnModelKnown(agent any) bool { _, ok := agent.(turnModelDoor); return ok } + +// TurnModelSupported answers for THE MACHINE AT THE OTHER END, off what it said +// at the door. +func (a *Agent) TurnModelSupported() bool { return a.c.Welcome().TurnModel } + +// TurnModel is the model the turn in flight is on, and "" when nothing is in +// flight OR when the far engine does not keep the fact. +// +// THE TWO EMPTIES ARE DELIBERATELY THE SAME STRING HERE, and the caller tells +// them apart with [Agent.TurnModelSupported] before it draws anything — which is +// the same division [Agent.ResolvedEffort] and [Agent.EffortSupported] keep, for +// the same reason: a reading taken on every frame must not carry a second +// return nobody checks. +// +// IT IS A MEMORY READ, for [Agent.ResolvedEffort]'s stated reason. +func (a *Agent) TurnModel() string { return a.c.facts.read().TurnModel } diff --git a/internal/remote/wire.go b/internal/remote/wire.go index 9af4290009..3eb981650b 100644 --- a/internal/remote/wire.go +++ b/internal/remote/wire.go @@ -340,7 +340,16 @@ import ( // half a minute while this surface had stopped waiting at ten seconds: the // person would be told their work did not start while it did. NEVER TO A // SENTENCE THAT IS FALSE. -const Version = 16 +// AND 17 IS THE MODEL THE TURN IN FLIGHT IS ON ([Welcome.TurnModel], +// [session.Facts.TurnModel]). The number moves because the surface draws a +// SENTENCE off it — `this turn finishes on ` — and a version-16 engine +// sends no such fact while its conversations still run turns: a surface that +// filled the gap from the dial would say that sentence with the model the person +// had just picked in both halves, which is a claim about the machine at the far +// end that nothing at the far end made. NEVER TO A SENTENCE THAT IS FALSE: at +// 16 the flag is absent, the surface draws the dial alone, and it says nothing +// about what a turn in flight is finishing on. +const Version = 17 // AND THE NEWS FRAMES RIDE THAT SAME NUMBER, for the reason the places methods // rode version 5's: neither half can be surprised by them. "phase" and "lane" @@ -1061,6 +1070,24 @@ type Welcome struct { // has never heard of. Effort bool `json:"effort,omitempty"` + // TurnModel says this engine CAN NAME THE MODEL THE TURN IN FLIGHT IS ON — + // that its agent answers [session.Agent.TurnModel] and states it on the fact + // set it pushes ([session.Facts.TurnModel]). + // + // IT IS CARRIED FOR [Welcome.Effort]'S REASON, AND THE STAKES ARE HIGHER. A + // surface at this end holds a *remote.Agent, which ALWAYS has the method, so + // the assertion says yes for every connection and nothing about the far + // machine. And the ANSWER cannot separate them either: "" is a real answer + // here — an idle conversation with nothing in flight — so an engine that has + // never heard of the latch and a conversation that is simply not working are + // the same empty string. + // + // ABSENCE IS false AND false MEANS THE DIAL ALONE. The seam names what the + // person picked, which is what it named before this existed, and the switch + // says nothing about a turn in flight — because the only honest thing to say + // about a fact the far machine does not keep is nothing. + TurnModel bool `json:"turnModel,omitempty"` + // Folders says this engine CAN HOLD THE FOLDERS A CONVERSATION IS ABOUT — // that its agent answers [MethodPlacesRefer] and [MethodPlacesRemove] rather // than refusing them (wire_places.go). diff --git a/internal/session/agent.go b/internal/session/agent.go index 1ea51a5b2f..1b965d952e 100644 --- a/internal/session/agent.go +++ b/internal/session/agent.go @@ -606,13 +606,47 @@ func (a *Agent) ContextTokens() int { return a.estimateTokensLocked() } -// Model returns the model the next request will use. +// Model returns the model the next request will use — THE DIAL, which is what a +// person's last choice set and not necessarily what anything is talking to right +// now. [Agent.TurnModel] is the other question. func (a *Agent) Model() string { a.mu.Lock() defer a.mu.Unlock() return a.model } +// TurnModel is THE MODEL THE TURN IN FLIGHT IS ON, and "" when nothing is in +// flight. +// +// IT IS THE ONE SOURCE FOR EVERY SENTENCE ABOUT WORK THAT IS ALREADY RUNNING — +// the phase clock the surface draws its model cell from (phasenews.go), the +// sentence a mid-turn pick says about what it did not touch, the loop's own +// latched model (loop.go). There is deliberately no second road: a fact with two +// spellings is two facts, and the defect this exists for was exactly that — the +// seam drew the dial's model beside an endpoint sighting from the wire, so one +// line named two different models and neither cell was wrong on its own terms. +// +// IT IS a.riding PUBLISHED WITH THE EMPTINESS LAW ON IT, and not a second latch +// beside it. The ladder already holds the model the step is talking to +// (steer.go's [Agent.rideModel], written at the turn's own opening by +// [Agent.latchTheModel] and moved only by a rescue's hop); what a surface needs +// on top of that is the one thing [Agent.ridingNowLocked] deliberately does not +// say — that there is no turn at all — because a cell that fell back to the dial +// silently is the cell that named a model for seventeen minutes after the person +// moved off it. So idle answers "", and the caller decides what an idle +// conversation draws. +func (a *Agent) TurnModel() string { + if a == nil { + return "" + } + a.mu.Lock() + defer a.mu.Unlock() + if !a.running { + return "" + } + return a.riding +} + // SetModel swaps the model, and A PERSON'S WORD WINS AT THE NEXT REQUEST. // // A swap made while the agent is working reaches the work through steer.go's one @@ -627,6 +661,11 @@ func (a *Agent) Model() string { // arriving mid-stream cannot send one model the transcript another model was // half-way through writing (loop.go). // +// SO WHAT THE CHROME SAYS ABOUT THE PICK IS [ModelLanding] AND NOT A GUESS. The +// work goes on being drawn from [Agent.TurnModel] either way, which is what +// makes the two sentences — the pick landed now, or the answer you are reading +// finishes first — both true of the same screen. +// // THE TURN ITSELF MAY STILL MOVE, and this is the one thing that moves it. A // step whose stream is cut over and over spends a budget and then hops to the // next model in the chain, announced, and the rest of that turn finishes there @@ -634,13 +673,15 @@ func (a *Agent) Model() string { // person set here is untouched, so the NEXT turn starts on the model they // picked, and the only way this field changes is somebody calling this. // -// AND IT IS WHERE THE PICTURES ARE MADE SAFE. A conversation carrying attached -// images carries them as base64 in the live transcript, re-sent on every step -// of every turn after they arrived; swapping onto a model that cannot see would -// send them to it with no gate in the way, because no image is being attached -// this turn. [Agent.scrubBlindImagePartsLocked] states the whole rule and its -// three deliberate limits — the journal is untouched, the swap is one-way, and a -// model that CAN see is handed everything unchanged. +// THE PICTURES ARE NOT MADE SAFE HERE, and that is the change this door owes +// its own paragraph. A conversation carrying attached images carries them as +// base64 in the live transcript, re-sent on every request after they arrived, so +// moving onto a model that cannot see would send them to it with no gate in the +// way — no image is being attached. The scrub therefore rides THE MODEL THE WORK +// IS TALKING TO rather than the dial ([Agent.ridesOnLocked], steer.go): the dial +// is a preference that may never be acted on, and under the law above a pick can +// reach the request in flight, which a scrub that ran only here would have +// missed by one request. func (a *Agent) SetModel(model string) { a.setModel(model) } // setModel is SetModel with the answer to "when does this land", which the doors @@ -688,7 +729,9 @@ func (a *Agent) setModel(model string) ModelLanding { // fact from the card's figure above and travels with the model rather than // with the session (loop.go's [Agent.noteModelWindow]). a.noteModelWindow(model) - a.scrubBlindImagePartsLocked(model) + // AND THE PICTURES ARE ONLY MADE SAFE WHEN NOTHING IS IN FLIGHT, which is + // the same guard [Agent.rebindClientLocked] is under above and for the same + // reason said about a different resource. THE TURN IN FLIGHT IS ON THE MODEL a.mu.Unlock() // AND THE BEAT IS TOLD, OUTSIDE THE LOCK. Everything above is about this // session's own state; this is about a fetch somebody else will do, and a @@ -1590,6 +1633,17 @@ func (u userMessage) text() string { return messageContentText(u.message) } func (a *Agent) startTurnLocked(ctx context.Context, user userMessage, watcher *eventStream, extra ...*eventStream) <-chan Event { a.rebindClientLocked(a.model) a.running = true + // AND THE TURN LATCHES THE MODEL IT WILL RUN ON, HERE, in the same breath as + // the client it was just bound to and under the same lock. The loop used to + // read the dial again on the other side of the goroutine hand-off, which + // left a window — small, but real — in which a person's pick landed between + // the binding and the reading and the turn talked to one model through a + // client built for another. Everything that has to name the work in flight + // reads this latch afterwards ([Agent.TurnModel]). + // AND THE PICTURES ARE MADE SAFE FOR IT IN THE SAME BREATH, through the one + // door a move goes through (steer.go's [Agent.ridesOnLocked]), which scrubs + // only when the model actually moved. + a.ridesOnLocked(a.model) a.lastTurnTruncated = false // AND ANOTHER WINDOW HEARS ABOUT IT NOW rather than at the next heartbeat // (taskpresence.go). The nudge never blocks and never takes a lock, which is diff --git a/internal/session/blindswap.go b/internal/session/blindswap.go index 3f4977a9f2..b0739ef6b8 100644 --- a/internal/session/blindswap.go +++ b/internal/session/blindswap.go @@ -34,11 +34,17 @@ import "github.com/Agent-Field/agentfield/sdk/go/ai" // no-op — not one allocation — for a model that can see and for the // conversations, which are nearly all of them, that carry no pictures. // -// A NIL SupportsImages scrubs, by Config's own law that nil is false here: a -// surface holding no catalog has not vouched for this model, and an unvouched -// model is not one to send base64 to. +// IT SCRUBS ON A POSITIVE `BLIND` AND ON NOTHING ELSE ([ModelSight]). An +// unvouched model — a cold catalog, a failed fetch, a row that published no +// modalities — keeps its pictures. That asymmetry with the send gate, which +// refuses on anything but a positive `sees`, is the point: refusing to send +// costs a person one turn and a sentence, and scrubbing costs them the +// screenshot, for ever, with no way back. On 2026-09-11 the two shared one bool +// and the same model id answered `sees` off the disk cache and `blind` a minute +// later off a catalog fetch that had failed — which was enough to strip a +// conversation's pictures with nobody having swapped anything. func (a *Agent) scrubBlindImagePartsLocked(model string) { - if a.config.SupportsImages != nil && a.config.SupportsImages(model) { + if a.sightOf(model) != SightBlind { return } for index, message := range a.messages { @@ -79,3 +85,46 @@ func hasImagePart(message ai.Message) bool { } return false } + +// ── WHAT THIS BUILD KNOWS ABOUT A MODEL'S EYES ────────────────────────────── + +// ModelSight is the answer to "can this model read a picture", in THREE states, +// because two were not enough to hold both decisions that ask. +// +// THE THIRD STATE IS THE ONE THAT MATTERS. A catalog that has not loaded, a +// fetch that failed, a model nobody published modalities for — all of them are +// "nobody has said", and for a whole release that was spelled the same way as +// "this model is blind". The send gate was right to refuse on it. The scrub was +// wrong to fire on it, and the scrub is the one that cannot be undone. +type ModelSight uint8 + +const ( + // SightUnknown is nobody having vouched either way. It REFUSES A SEND and + // FORBIDS A SCRUB: neither act is safe on a guess, and they fail in opposite + // directions, so the same ignorance answers them differently. + SightUnknown ModelSight = iota + // SightBlind is a model this build knows cannot read pictures. + SightBlind + // SightSees is a model this build knows can. + SightSees +) + +// SightOf is a yes-or-no oracle's answer as a sight, for a caller that really +// does know both ways — a fixture, a catalog row that carries modalities. A +// caller that might be ignorant must say [SightUnknown] itself rather than +// passing false, which is exactly the collapse this type exists to prevent. +func SightOf(sees bool) ModelSight { + if sees { + return SightSees + } + return SightBlind +} + +// sightOf is what this session knows about one model, with [SightUnknown] for a +// session wired with no oracle at all. +func (a *Agent) sightOf(model string) ModelSight { + if a.config.SeesImages == nil { + return SightUnknown + } + return a.config.SeesImages(model) +} diff --git a/internal/session/blindswap_test.go b/internal/session/blindswap_test.go index 98052e9c4d..1d1dcba114 100644 --- a/internal/session/blindswap_test.go +++ b/internal/session/blindswap_test.go @@ -47,7 +47,7 @@ func countImageParts(agent *Agent) int { func TestSetModelScrubsImagePartsForAModelThatCannotSee(t *testing.T) { completer := &scriptedCompleter{} agent, workspace := newTestAgent(t, completer, func(config *Config) { - config.SupportsImages = func(model string) bool { return model == "test/model" } + config.SeesImages = func(model string) ModelSight { return SightOf(model == "test/model") } config.SessionFile = writeableJournal(t) }) path := writeImage(t, workspace, "chart.png", "PHOTOBYTES") @@ -150,11 +150,11 @@ func TestResumingOnABlindModelScrubsTheReplayedPictures(t *testing.T) { } resumed, err := newAgent(Config{ - Workspace: workspace, - Model: "vendor/blind", - System: "SYSTEM", - SessionFile: journal, - SupportsImages: func(model string) bool { return model == "test/model" }, + Workspace: workspace, + Model: "vendor/blind", + System: "SYSTEM", + SessionFile: journal, + SeesImages: func(model string) ModelSight { return SightOf(model == "test/model") }, }, &scriptedCompleter{}) if err != nil { t.Fatalf("resume: %v", err) @@ -174,7 +174,7 @@ func TestResumingOnABlindModelScrubsTheReplayedPictures(t *testing.T) { // send base64 to a blind model. func TestTheScrubDropsBytesEvenWithNoJournaledPath(t *testing.T) { agent, _ := newTestAgent(t, &scriptedCompleter{}, func(config *Config) { - config.SupportsImages = func(model string) bool { return model == "test/model" } + config.SeesImages = func(model string) ModelSight { return SightOf(model == "test/model") } }) agent.mu.Lock() agent.messages = append(agent.messages, ai.Message{Role: "user", Content: []ai.ContentPart{ diff --git a/internal/session/effort.go b/internal/session/effort.go index 67de11ccb4..5fbe4b7b20 100644 --- a/internal/session/effort.go +++ b/internal/session/effort.go @@ -67,7 +67,28 @@ func (a *Agent) ConversationEffort() string { // whichever scope decided it. It is what a surface shows when it wants to say // what is HAPPENING rather than what was chosen. func (a *Agent) ResolvedEffort() string { - return a.effortFor(a.Model()).String() + return a.ResolvedEffortFor(a.Model()) +} + +// ResolvedEffortFor is that rung FOR ONE MODEL ID, which is a different question +// whenever a level has been dialled onto a model in the picker: those live per +// model id ([Agent.ReasoningFor]), and they are the most specific scope the +// ladder has, so they decide. +// +// IT EXISTS BECAUSE THE RUNG IS DRAWN BESIDE A MODEL AND MUST BE THAT MODEL'S. +// The seam's cells read left to right as one sentence — this model, thinking +// this hard, served by this machine — and the model cell names the model the +// ENGINE is on (internal/tui3's [app.wireModel]). Resolving the rung for the +// dial instead put one model's name next to another model's level the moment +// somebody picked mid-turn, and a fallback hop moved the turn's rung +// (loop.go's `moveOn` re-resolves) without moving the drawn one. +// +// An empty id is the dial, which is what an idle conversation's cell names. +func (a *Agent) ResolvedEffortFor(model string) string { + if strings.TrimSpace(model) == "" { + model = a.Model() + } + return a.effortFor(model).String() } // SetConversationEffort sets this conversation's rung and reports whether the diff --git a/internal/session/facts.go b/internal/session/facts.go index 324d6f807c..49c9b831ae 100644 --- a/internal/session/facts.go +++ b/internal/session/facts.go @@ -71,6 +71,20 @@ type Facts struct { // the dial itself — and a fact named one thing in the protocol and another on // the screen is two vocabularies for one ladder. Thinking string `json:"thinking,omitempty"` + // TurnModel is THE MODEL THE TURN IN FLIGHT IS ON ([Agent.TurnModel]), and + // empty when nothing is in flight. + // + // IT IS A DIFFERENT FACT FROM [Welcome.Model], WHICH IS THE DIAL. The two + // part company the moment somebody picks a model while a turn is running: the + // turn finishes on the model it started on, every step and every retry of it, + // and the chrome has to be able to say so. It rides the photograph rather + // than a call because the seam draws it on every frame, which is the same + // bargain [Facts.Thinking] beside it makes. + // + // AN ENGINE THAT DOES NOT CARRY IT SENDS NOTHING, and a surface must be able + // to tell that from a genuinely idle conversation — which is what + // [remote.Welcome]'s own flag is for, exactly as it is for the dial. + TurnModel string `json:"turnModel,omitempty"` // Places is the folders this conversation is about, newest first // ([Agent.Places]) — the person's own attachments among them, told apart by // [PlaceRef.Arrival]. @@ -151,13 +165,29 @@ func FactsOf(source FactSource) Facts { if door, ok := source.(interface{ NeedsPerson() bool }); ok { facts.NeedsPerson = door.NeedsPerson() } + // AND THE MODEL THE WORK IN FLIGHT IS ON, asserted for the folders' reason. + // It is read BEFORE the rung because the rung is resolved FOR it. + if door, ok := source.(interface{ TurnModel() string }); ok { + facts.TurnModel = door.TurnModel() + } // AND THE THINKING RUNG, ASSERTED FOR THE FOLDERS' REASON. A source with no // dial on it is not a conversation thinking at nothing: it is one nobody can // ask, and the surface at the other end of a wire must be able to tell those // two apart — which is what [remote.Welcome]'s own flag is for. A CAPABILITY // THAT CANNOT WORK IS ABSENT, NOT BROKEN, so the field stays empty here and // the dial is simply not drawn. - if door, ok := source.(interface{ ResolvedEffort() string }); ok { + // + // AND IT IS RESOLVED FOR THE MODEL THE PHOTOGRAPH NAMES. The rung is drawn + // beside the model cell and the model cell names [Facts.TurnModel] while + // there is one, so resolving for the dial would put one model's name next to + // another model's level — the levels are per model id, so the two really can + // differ. A source that cannot resolve per model answers for the dial, which + // is what it always did. + if door, ok := source.(interface { + ResolvedEffortFor(model string) string + }); ok { + facts.Thinking = door.ResolvedEffortFor(facts.TurnModel) + } else if door, ok := source.(interface{ ResolvedEffort() string }); ok { facts.Thinking = door.ResolvedEffort() } return facts diff --git a/internal/session/image.go b/internal/session/image.go index 56bbbe630f..d1e378b1f2 100644 --- a/internal/session/image.go +++ b/internal/session/image.go @@ -110,7 +110,10 @@ func (a *Agent) SubmitImage(ctx context.Context, text string, images []Image) (< // keeps the refusal cheap: nothing is read from disk for a model that could // not have looked at it, and the fallback's own resolution is cheaper still. model := a.Model() - if a.config.SupportsImages == nil || !a.config.SupportsImages(model) { + // A POSITIVE `sees` OR NOTHING. Unknown goes to the looking model exactly as + // blind does: neither is a model this build may hand base64 to + // ([ModelSight]). + if a.sightOf(model) != SightSees { return a.visionTurn(ctx, text, images, model) } @@ -402,6 +405,12 @@ func visionUserMessage(text string, refs []journalPart) userMessage { // half is this function and it should move to agent.go beside the original. func (a *Agent) startVisionTurnLocked(ctx context.Context, kept userMessage, live ai.Message, seer string) <-chan Event { a.running = true + // AND THIS TURN'S MODEL IS THE LOOKING MODEL, because that is the only model + // this turn talks to: one call, to whoever can see the pictures. The chrome + // names the model the engine is on ([Agent.TurnModel]), and for the length of + // a vision turn the engine is on the seer — which is also what the reply is + // prefixed with, so the line and the reply agree. + a.ridesOnLocked(seer) hub := newEventHub() a.hub = hub turnCtx, cancel := context.WithCancelCause(ctx) @@ -511,13 +520,13 @@ func (a *Agent) runVision(ctx context.Context, hub *eventHub, live ai.Message, s partial.reset() } if ctx.Err() != nil { - hub.send(Event{Kind: EventTurnDone, Usage: a.sealTurn(Usage{}, started, a.Model())}) + hub.send(Event{Kind: EventTurnDone, Usage: a.sealTurn(Usage{}, started, seer)}) return false } if err == nil { err = fmt.Errorf("session: %s returned no answer for the image", seer) } - hub.send(Event{Kind: EventError, Err: err, Usage: a.sealTurn(Usage{}, started, a.Model())}) + hub.send(Event{Kind: EventError, Err: err, Usage: a.sealTurn(Usage{}, started, seer)}) return false } @@ -530,7 +539,7 @@ func (a *Agent) runVision(ctx context.Context, hub *eventHub, live ai.Message, s } partial.reset() a.record(textMessage("assistant", note+answer)) - hub.send(Event{Kind: EventTurnDone, Usage: a.sealTurn(Usage{}, started, a.Model())}) + hub.send(Event{Kind: EventTurnDone, Usage: a.sealTurn(Usage{}, started, seer)}) // The session may name itself off this exchange like any other: a // conversation that opened with a photograph is still a conversation about // something (title.go). diff --git a/internal/session/image_test.go b/internal/session/image_test.go index ad15cf7b98..646efc6e69 100644 --- a/internal/session/image_test.go +++ b/internal/session/image_test.go @@ -31,12 +31,12 @@ func writeImage(t *testing.T, directory, name, content string) string { return path } -// noGate leaves Config.SupportsImages nil — a caller that holds no catalog. +// noGate leaves Config.SeesImages nil — a caller that holds no catalog. func noGate(*Config) {} // withVision is the gate saying yes. func withVision(config *Config) { - config.SupportsImages = func(string) bool { return true } + config.SeesImages = func(string) ModelSight { return SightOf(true) } } func imagePartURLs(message ai.Message) []string { @@ -148,7 +148,7 @@ func TestSubmitImageRefusesAModelThatCannotSeeAndNamesIt(t *testing.T) { // that holds no catalog must not be able to send parts to a model // nobody has vouched for. {"nil gate", noGate}, - {"gate says no", func(config *Config) { config.SupportsImages = func(string) bool { return false } }}, + {"gate says no", func(config *Config) { config.SeesImages = func(string) ModelSight { return SightOf(false) } }}, } { t.Run(testCase.name, func(t *testing.T) { completer := &scriptedCompleter{} @@ -186,7 +186,7 @@ func TestSubmitImageRefusesAModelThatCannotSeeAndNamesIt(t *testing.T) { func TestSubmitImageGateFollowsTheCurrentModel(t *testing.T) { completer := &scriptedCompleter{} agent, workspace := newTestAgent(t, completer, func(config *Config) { - config.SupportsImages = func(model string) bool { return model == "vendor/sees" } + config.SeesImages = func(model string) ModelSight { return SightOf(model == "vendor/sees") } }) path := writeImage(t, workspace, "shot.png", "BYTES") @@ -494,12 +494,12 @@ func TestImagePartsReachTheWire(t *testing.T) { workspace := t.TempDir() agent, err := New(Config{ - Workspace: workspace, - Model: "vendor/vision-model", - APIKey: "test", - BaseURL: server.URL, - System: "SYSTEM", - SupportsImages: func(string) bool { return true }, + Workspace: workspace, + Model: "vendor/vision-model", + APIKey: "test", + BaseURL: server.URL, + System: "SYSTEM", + SeesImages: func(string) ModelSight { return SightOf(true) }, }) if err != nil { t.Fatalf("New: %v", err) diff --git a/internal/session/loop.go b/internal/session/loop.go index 0636f5ce1d..532d10d643 100644 --- a/internal/session/loop.go +++ b/internal/session/loop.go @@ -673,6 +673,14 @@ func (a *Agent) runTurn(ctx context.Context, hub *eventHub, user userMessage) bo // transcript another model was half-way through writing — and the boundary is // what stops a person's pick waiting out a twenty-minute step. // + // THE LATCH IS THE AGENT'S AND IS READ BACK, NOT TAKEN AGAIN HERE. It was + // stamped when the turn opened, under the lock that bound the client + // ([Agent.startTurnLocked]); reading the dial again on this side of the + // goroutine hand-off was a second latch of the same fact, one instant later, + // and a pick that landed in between made the two disagree. It is also what + // every sentence a person reads about this turn is drawn from + // ([Agent.TurnModel]), so the turn and the chrome cannot name two models. + // // The effort rung is latched WITH it, in the same breath and for the same // reason — and it is resolved for THIS model, so a swap mid-turn cannot // leave the turn sending one model's level with another model's name. It is @@ -869,6 +877,7 @@ func (a *Agent) runTurn(ctx context.Context, hub *eventHub, user userMessage) bo // model that actually answered rather than the one that stopped. if answered != "" && answered != model { model = answered + a.rideModel(model) rung = a.effortFor(model) } if err != nil { @@ -1997,6 +2006,14 @@ func (a *Agent) completeWithRetryReasoning(ctx context.Context, hub *eventHub, m // before it had answered once. It is [takeTheModel] above, the one // place this step changes model, which the request boundary reaches // through the same call. + // + // AND IT IS WHAT MOVES THE TURN'S PUBLISHED MODEL WITH IT, because a + // hop IS the turn changing what it is talking to — the one thing that + // may move it ([Agent.TurnModel], published by [Agent.rideModel] + // inside `freshModel`). Everything drawn about this turn from here on + // names the model that is actually answering: the phase clock above, + // the seam's model cell, and the sentence a mid-turn pick says about + // what it did not touch. takeTheModel(to, theirs) // The attempt counter is put one BEHIND its first rung, because the // loop's own post-statement is what advances it. diff --git a/internal/session/orchestrate.go b/internal/session/orchestrate.go index f47e73a7b1..f6e4d26270 100644 --- a/internal/session/orchestrate.go +++ b/internal/session/orchestrate.go @@ -1093,7 +1093,7 @@ func (e *orchestrateExec) newChild(dir string, node orchestrate.Node) (*Agent, e AskConsent: false, InTask: true, writeScope: node.WriteScope, - SupportsImages: parent.SupportsImages, + SeesImages: parent.SeesImages, RolesSource: parent.RolesSource, // Beside the ladder it overrides, for task_run.go's reason. OneModel: parent.OneModel, diff --git a/internal/session/phasenews.go b/internal/session/phasenews.go index c39010b775..bf594e8dbb 100644 --- a/internal/session/phasenews.go +++ b/internal/session/phasenews.go @@ -399,9 +399,7 @@ func (a *Agent) tellPhaseThen(phase provider.Phase, detail, then string, since t if a == nil { return } - a.mu.Lock() - model := a.model - a.mu.Unlock() + model := a.newsModel() now := time.Now() if since.IsZero() { since = now @@ -437,6 +435,26 @@ func (a *Agent) tellPhaseThen(phase provider.Phase, detail, then string, since t guard.Go("phase beat", func() { a.beatHeldPhase(stop) }) } +// newsModel is THE MODEL A PIECE OF NEWS IS ABOUT: the turn in flight's latched +// model while a turn is running, and the dial when nothing is. +// +// THE NEWS IS ABOUT THE ENGINE AND NOT ABOUT THE PICKER. Every stage a surface +// draws — running a tool, checking an answer, tidying, taking stock — belongs to +// a turn, and that turn is on one model from the moment it opened +// ([Agent.TurnModel]). Reading the dial here was the engine half of one reported +// line naming two models: a person picked kimi-k3 three seconds into a turn and +// every stage posted afterwards claimed the seventeen minutes of glm-5.3-flash +// that followed were kimi-k3's, while the endpoint sighting beside them went on +// naming the machine that was really answering. +// +// THE DIAL IS THE RIGHT ANSWER WHEN NOTHING IS RUNNING, which is what an idle +// surface should say: the model the next request will use. +func (a *Agent) newsModel() string { + a.mu.Lock() + defer a.mu.Unlock() + return a.ridingNowLocked() +} + // interruptPhase says a phase over the top of whatever the turn was already // saying, and hands back the way to put the old one back. // @@ -476,9 +494,7 @@ func (a *Agent) endPhase() { if a == nil { return } - a.mu.Lock() - model := a.model - a.mu.Unlock() + model := a.newsModel() // THE END OF A STAGE NAMES THE SAME SUBJECT THE START DID, or it would clear // somebody else's clock: a surface files an empty phase by removing that // subject's entry, and an unnamed end would take the conversation's row down diff --git a/internal/session/replay_test.go b/internal/session/replay_test.go index d0625c290c..a0ab53a37c 100644 --- a/internal/session/replay_test.go +++ b/internal/session/replay_test.go @@ -251,7 +251,7 @@ func TestAResumedMessageCarriesItsImageReferences(t *testing.T) { // Every other message answers nothing, which is what keeps a marker off a // line nobody attached anything to. The person's own line is matched by its // opening words rather than whole: this session was reopened with no - // SupportsImages, which is "nobody vouched for this model" and therefore NO + // SeesImages, which is "nobody vouched for this model" and therefore NO // by Config's law, so the transcript guard has replaced the picture with its // placeholder and the line now carries that sentence too (image.go). for _, entry := range second.Transcript() { diff --git a/internal/session/session.go b/internal/session/session.go index 794e88ebd4..0f3a82bbaf 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -1278,19 +1278,26 @@ type Config struct { // model — roles.Resolve's floor, not a failure. RolesSource func(key string) (string, bool) - // SupportsImages reports whether a model can read image content parts. It - // gates [Agent.SubmitImage] and NIL IS FALSE — the opposite of every other - // nil-is-permissive hook here, and deliberately so: a model that cannot see - // answers a message full of image parts with a 400 or, worse, with a - // confident description of nothing. "I don't know whether this model has - // vision" and "this model has vision" must not be spelled the same way, so a - // caller that holds no catalog gets a refusal it can read instead of a turn - // that fails on the wire. - // - // It is a function of the model rather than a bool because the model moves: + // SeesImages reports what this build KNOWS about a model's eyes, in three + // answers and not two ([ModelSight]). NIL IS [SightUnknown], which is the + // honest reading of a caller that holds no catalog. + // + // TWO DECISIONS READ IT AND THEY READ IT DIFFERENTLY, which is the whole + // reason it is not a bool. Sending pictures needs a positive YES: a model + // that cannot see answers a message full of image parts with a 400 or, worse, + // with a confident description of nothing, so [Agent.SubmitImage] refuses on + // anything but [SightSees]. Taking pictures OUT of a live transcript needs a + // positive NO: the scrub is one-way and the bytes do not come back + // (blindswap.go), so it runs on [SightBlind] and on nothing else. Spelled as + // one bool those two were forced to share a reading, and the shared reading + // was "unknown counts as blind" — which quietly destroyed a person's + // screenshot whenever a catalog fetch failed and the same model id answered + // differently than it had a minute earlier. + // + // It is a function of the model rather than a value because the model moves: // /model swaps it mid-session (see [Agent.SetModel]), and the answer has to // follow the model the next turn will actually ride. - SupportsImages func(model string) bool + SeesImages func(model string) ModelSight // ReasoningProfile is the row's account of a model's thinking pass — // whether it can be turned off, which effort words it takes — under the @@ -2559,6 +2566,14 @@ type Agent struct { // session that has never opened an episode. writes *writeMeter running bool + // scrubbedFor is the model this transcript's pictures were last made safe + // for ([Agent.scrubBlindImagePartsLocked]). It is the memory that turns the + // scrub from something every turn does into something A SWAP does: empty + // until the first turn, and thereafter the id of whoever the pictures were + // last checked against. A conversation that never changes model therefore + // pays the check once, and an oracle that changes its mind about one id + // cannot reach back into a transcript nobody swapped. + scrubbedFor string // turnFloor is where the running turn's WORK begins in a.messages: the // index just past the message that opened the turn, stamped by // [Agent.startTurnLocked] and meaningful only while running is true. It is @@ -2614,6 +2629,18 @@ type Agent struct { // same fact published, so the door that decides whether a pick is NEWS can // compare against the model the work is on rather than the one the session // remembers (steer.go's [Agent.rideModel]). + // + // IT IS ALSO WHAT EVERY SENTENCE ABOUT THE WORK IN FLIGHT IS DRAWN FROM, + // published to a surface by [Agent.TurnModel] with the emptiness law on it. + // a.model is THE DIAL — what the NEXT turn will use — and the two part + // company the moment somebody picks a model while work is running: on + // 2026-09-11 a pick made three seconds after a message left the + // conversation's chrome naming a model no request had been sent to for + // seventeen minutes. It is written at the turn's own opening and moved only + // by a move the work actually made, through the one door that also makes the + // pictures safe ([Agent.ridesOnLocked]), and it is NEVER CLEARED: `running` + // is the flag that says it means anything, and two flags to clear is one of + // them left set. riding string // recall is the pre-turn memory routing STARTED BESIDE THE TITLE and never // waited on (memory.go's [recallAside]). It is under mu because diff --git a/internal/session/steer.go b/internal/session/steer.go index 2a52afb149..0704ee6fde 100644 --- a/internal/session/steer.go +++ b/internal/session/steer.go @@ -486,10 +486,39 @@ func (a *Agent) latchTheModel() string { a.mu.Lock() defer a.mu.Unlock() a.spokenModel = "" - a.riding = a.model + a.ridesOnLocked(a.model) return a.model } +// ridesOnLocked publishes the model the work is talking to AND MAKES THE +// TRANSCRIPT'S PICTURES SAFE FOR IT, because those are one event and not two. +// +// THE SCRUB BELONGS TO THE MOVE, NOT TO THE PICKER. A conversation carrying +// attached images carries them as base64 in the live transcript and re-sends +// them with every request, so the moment that matters is the moment the work +// starts talking to a model that cannot see — whether that came from a person's +// pick taken at the request boundary, a rescue hopping onto a fallback, or a +// turn simply opening on the dial. [Agent.SetModel] scrubbing instead was the +// old shape and it was wrong at both ends: it scrubbed for a preference that +// may never be acted on, and under steer.go's law a pick can reach the REQUEST +// in flight, which the picker's scrub would have missed by one request. +// +// AND ONLY WHEN THE MODEL ACTUALLY MOVED. A conversation that has never changed +// model would otherwise ask the oracle on every turn, and that oracle can answer +// differently twice for one id — a catalog fetch that failed after a cached one +// succeeded turned `sees` into `blind` with nobody having touched the picker, +// and the second turn of that conversation ate the person's screenshot. THE +// MOVE IS THE SEAM, so the move — and nothing else — is what may scrub +// (blindswap.go states the rule and its three limits). +func (a *Agent) ridesOnLocked(model string) { + a.riding = model + if model == "" || strings.EqualFold(model, a.scrubbedFor) { + return + } + a.scrubBlindImagePartsLocked(model) + a.scrubbedFor = model +} + // rideModel publishes THE MODEL THE WORK IS ACTUALLY TALKING TO. The ladder has // always known it — it is the local the rescue chain moves — and until now // nothing outside the loop could read it, so the door that decides whether a @@ -506,7 +535,7 @@ func (a *Agent) latchTheModel() string { func (a *Agent) rideModel(model string) { a.mu.Lock() defer a.mu.Unlock() - a.riding = model + a.ridesOnLocked(model) } // ridingNowLocked is that fact when there is a step to have it, and the diff --git a/internal/session/task_run.go b/internal/session/task_run.go index 506ca62ff3..d14b9fdd3e 100644 --- a/internal/session/task_run.go +++ b/internal/session/task_run.go @@ -7321,8 +7321,8 @@ func (a *Agent) newTaskAgentOn(ctx context.Context, dir string, node *TaskNode, // model is never told it has. It is nil for every other node — the node // has no revision lane to close over — which is what keeps revise_design // off every other belt (harness_task.go's reviseDoor). - reviseDesign: node.reviseDoor(), - SupportsImages: parent.SupportsImages, + reviseDesign: node.reviseDoor(), + SeesImages: parent.SeesImages, // AND THE ANSWER TO "CAN THIS MODEL HOLD A TOOL", without which the // rescue above works exactly one level deep. It is read at the top of // THIS constructor, off the parent's config, so a worker that did not diff --git a/internal/session/tools_doc.go b/internal/session/tools_doc.go index d62ccd311d..3a6de327cd 100644 --- a/internal/session/tools_doc.go +++ b/internal/session/tools_doc.go @@ -403,7 +403,7 @@ func (a *Agent) documentModel(kind documentKind) string { if kind != documentImage { return model } - if a.config.SupportsImages != nil && a.config.SupportsImages(model) { + if a.sightOf(model) == SightSees { return model } if seer := a.visionSeer(); seer != "" { diff --git a/internal/session/tools_doc_test.go b/internal/session/tools_doc_test.go index f2d8561900..66a8a3e18a 100644 --- a/internal/session/tools_doc_test.go +++ b/internal/session/tools_doc_test.go @@ -497,7 +497,7 @@ func TestAPictureIsReadByTheLookingModelWhenTheSessionCannotSee(t *testing.T) { provider.DocumentParseNative: "INVOICE 3319 — total 412.00", }} agent, workspace := newDocumentAgent(t, parser, nil, func(config *Config) { - config.SupportsImages = func(model string) bool { return model == "vendor/slot-eyes" } + config.SeesImages = func(model string) ModelSight { return SightOf(model == "vendor/slot-eyes") } config.MediaModel = func(modality string) string { if modality == "vision" { return "vendor/slot-eyes" @@ -524,7 +524,7 @@ func TestAPictureStaysOnTheSessionModelWhenItCanSee(t *testing.T) { provider.DocumentParseNative: "INVOICE 3319 — total 412.00", }} agent, workspace := newDocumentAgent(t, parser, nil, func(config *Config) { - config.SupportsImages = func(string) bool { return true } + config.SeesImages = func(string) ModelSight { return SightOf(true) } config.MediaModel = func(string) string { return "vendor/slot-eyes" } }) name := dropFile(t, workspace, "receipt.png", []byte("\x89PNG\r\n\x1a\n")) @@ -544,7 +544,7 @@ func TestADocumentIsNeverRoutedToTheLookingModel(t *testing.T) { provider.DocumentParseNative: "PAGE ONE — the quarterly figures, in full sentences", }} agent, workspace := newDocumentAgent(t, parser, nil, func(config *Config) { - config.SupportsImages = func(string) bool { return false } + config.SeesImages = func(string) ModelSight { return SightOf(false) } config.MediaModel = func(string) string { return "vendor/slot-eyes" } }) name := dropFile(t, workspace, "scan.pdf", scannedPDF()) diff --git a/internal/session/tools_image_test.go b/internal/session/tools_image_test.go index fa816c0d08..7c09e7bf78 100644 --- a/internal/session/tools_image_test.go +++ b/internal/session/tools_image_test.go @@ -338,7 +338,7 @@ func TestGenerateImageUsageFoldsIntoTheSessionTotal(t *testing.T) { // blindWithVision is a session whose model cannot see and whose vision role // resolves to one that can. func blindWithVision(config *Config) { - config.SupportsImages = func(string) bool { return false } + config.SeesImages = func(string) ModelSight { return SightOf(false) } config.RolesSource = pinnedSource(map[string]string{ roles.PinKey(roles.RoleVision): "vendor/eyes", }) @@ -475,16 +475,16 @@ func TestVisionFallbackRefusesOnlyWhenNoVisionModelResolves(t *testing.T) { mutate func(*Config) }{ {"no settings at all", func(config *Config) { - config.SupportsImages = func(string) bool { return false } + config.SeesImages = func(string) ModelSight { return SightOf(false) } }}, {"settings with nothing for vision", func(config *Config) { - config.SupportsImages = func(string) bool { return false } + config.SeesImages = func(string) ModelSight { return SightOf(false) } config.RolesSource = pinnedSource(map[string]string{"roles.title": "cheap/model"}) }}, // A tier pointed at the model we just established cannot see is a // configuration, not a capability. {"vision resolves to the blind model itself", func(config *Config) { - config.SupportsImages = func(string) bool { return false } + config.SeesImages = func(string) ModelSight { return SightOf(false) } config.RolesSource = pinnedSource(map[string]string{ roles.TierKey(roles.TierHigh): "vendor/blind", }) diff --git a/internal/session/turnmodel_law_test.go b/internal/session/turnmodel_law_test.go new file mode 100644 index 0000000000..63fa85df0d --- /dev/null +++ b/internal/session/turnmodel_law_test.go @@ -0,0 +1,126 @@ +package session + +// THE LAW: THE TURN'S MODEL IS LATCHED IN ONE PLACE PER KIND OF TURN, AND MOVED +// BY ONE FUNCTION. +// +// `a.turnModel` is what every sentence about work in flight is drawn from — the +// phase news a surface's model cell reads ([Agent.newsModel]), the loop's own +// per-step model (loop.go), the sentence `/model` says about what a swap did not +// touch. A second place that writes it is a second answer to "what is this turn +// on", and the defect this whole change closes was exactly two answers to that +// question drawn side by side on one line. +// +// So it may be written by three functions and no fourth: +// +// - [Agent.startTurnLocked], where an ordinary turn is born, under the lock +// that binds its client; +// - [Agent.startVisionTurnLocked], where the one other kind of turn is born +// and the model it talks to is the looking model; +// - [Agent.turnMovedTo], the one move — a rescue hopping a spent model to the +// next in the chain, announced in the same breath. +// +// AND IT IS NEVER CLEARED, which is the other half of the law and is why no +// fourth writer is needed: [Agent.turnModelLocked] answers "" unless a turn is +// running, so the flag that says there is work in flight is the flag that says +// this latch means anything. A clear would be a second thing to keep in step +// with `running`, and the first time the two disagreed the chrome would name a +// model nothing was talking to — which is the defect, back again by another +// road. + +import ( + "go/ast" + "go/token" + "testing" +) + +// turnModelWriters are the only functions that may assign the latch. +var turnModelWriters = map[string]bool{ + "startTurnLocked": true, + "startVisionTurnLocked": true, + "turnMovedTo": true, +} + +func TestOnlyTheTurnsOwnDoorsLatchItsModel(t *testing.T) { + written := map[string]bool{} + forEachPackageFile(t, func(path string, file *ast.File, fset *token.FileSet) { + for _, decl := range file.Decls { + fn, ok := decl.(*ast.FuncDecl) + if !ok || fn.Body == nil { + continue + } + ast.Inspect(fn.Body, func(node ast.Node) bool { + assign, ok := node.(*ast.AssignStmt) + if !ok { + return true + } + for _, target := range assign.Lhs { + selector, ok := target.(*ast.SelectorExpr) + if !ok || selector.Sel.Name != "turnModel" { + continue + } + written[fn.Name.Name] = true + if !turnModelWriters[fn.Name.Name] { + t.Errorf("%s writes the turn's latched model at %s — "+ + "the latch is stamped where a turn is born and moved only by turnMovedTo", + fn.Name.Name, fset.Position(selector.Pos())) + } + } + return true + }) + } + }) + // AND EVERY DOOR IS STILL A DOOR. A writer that lost its assignment is a kind + // of turn whose model nothing latches, which draws the LAST turn's model on + // the chrome for the length of this one. + for door := range turnModelWriters { + if !written[door] { + t.Errorf("%s no longer latches the turn's model, so the work it starts names whatever the turn before it was on", door) + } + } +} + +// turnModelReaders are the places inside a turn that must name the model THE +// TURN is on rather than the dial — the sentence a person reads about work in +// flight, and the row the work is written down on. +// +// The vision turn is the one that got this wrong for a whole release: it ran on +// the looking model and sealed itself with [Agent.Model], so the usage row, the +// journal and every reader downstream recorded a turn on a model that had not +// been asked anything. The seal takes `seer` now, which is the same fact the +// reply's own `[vision: ]` prefix carries. +var turnModelReaders = map[string]bool{"runVision": true} + +func TestTheWorkInFlightIsSealedUnderItsOwnModel(t *testing.T) { + seen := map[string]bool{} + forEachPackageFile(t, func(path string, file *ast.File, fset *token.FileSet) { + for _, decl := range file.Decls { + fn, ok := decl.(*ast.FuncDecl) + if !ok || fn.Body == nil || !turnModelReaders[fn.Name.Name] { + continue + } + seen[fn.Name.Name] = true + ast.Inspect(fn.Body, func(node ast.Node) bool { + call, ok := node.(*ast.CallExpr) + if !ok { + return true + } + selector, ok := call.Fun.(*ast.SelectorExpr) + if !ok || selector.Sel.Name != "Model" { + return true + } + if ident, ok := selector.X.(*ast.Ident); !ok || ident.Name != "a" { + return true + } + t.Errorf("%s reads the dial at %s — a turn in flight is named, drawn and "+ + "sealed under the model it is actually running on", + fn.Name.Name, fset.Position(selector.Pos())) + return true + }) + } + }) + for name := range turnModelReaders { + if !seen[name] { + t.Errorf("%s is gone, so this law is guarding nothing", name) + } + } +} diff --git a/internal/session/turnmodel_test.go b/internal/session/turnmodel_test.go new file mode 100644 index 0000000000..9139190535 --- /dev/null +++ b/internal/session/turnmodel_test.go @@ -0,0 +1,205 @@ +package session + +// THE LAW: A TURN IN FLIGHT IS ON ONE MODEL, IT SAYS WHICH, AND A SWAP MADE +// UNDER IT CHANGES NOTHING ABOUT IT. +// +// [Agent.SetModel] has promised since it was written that a turn finishes on the +// model it started on, and [TestSetModelAppliesFromTheNextTurn] has held it to +// that on the wire. What was missing is everything that SAYS so: the phase news +// a surface draws its model cell from carried the dial, so a pick made three +// seconds into a turn re-labelled the whole of it, and the transcript's pictures +// were taken away from a model that was in the middle of reading them. + +import ( + "context" + "testing" + "time" + + "github.com/Agent-Field/aforge-v2/internal/provider" + "github.com/Agent-Field/agentfield/sdk/go/ai" +) + +// A swap made mid-turn leaves the turn's own model, the news about it, and the +// pictures it is still sending exactly where they were — and lands whole at the +// next turn, scrub included. +func TestAMidTurnSwapLeavesTheRunningTurnAlone(t *testing.T) { + entered := make(chan struct{}) + release := make(chan struct{}) + completer := &scriptedCompleter{steps: []step{ + func(context.Context, []ai.Message) (*ai.Response, error) { + close(entered) + <-release + return toolResponse("c1", "ls", `{"path":"."}`), nil + }, + func(context.Context, []ai.Message) (*ai.Response, error) { + return textResponse("done"), nil + }, + func(context.Context, []ai.Message) (*ai.Response, error) { + return textResponse("on the new one"), nil + }, + }} + agent, workspace := newTestAgent(t, completer, func(config *Config) { + config.SeesImages = func(model string) ModelSight { return SightOf(model == "test/model") } + config.SessionFile = writeableJournal(t) + }) + phases := watchPhases(t) + path := writeImage(t, workspace, "chart.png", "PHOTOBYTES") + + ctx, cancel := deadline(20 * time.Second) + defer cancel() + events := mustSubmitImage(t, agent, ctx, "what is wrong with this?", []Image{{Path: path}}) + select { + case <-entered: + case <-time.After(10 * time.Second): + t.Fatal("the first step never started") + } + if got := agent.TurnModel(); got != "test/model" { + t.Fatalf("the turn in flight says it is on %q, want test/model", got) + } + + agent.SetModel("vendor/blind") + + // The dial moved and the turn did not. These are two different questions and + // this is the whole of the reported defect: the surface asked the first one + // and drew the answer as though it were the second. + if got := agent.Model(); got != "vendor/blind" { + t.Fatalf("the dial reads %q, want the model just picked", got) + } + if got := agent.TurnModel(); got != "test/model" { + t.Fatalf("the turn in flight moved to %q — SetModel touched work already running", got) + } + // AND THE PICTURES ARE STILL THERE. The steps left in this turn are still + // going to a model that can see them; replacing them with placeholders here + // takes a person's screenshot away mid-read. + if got := countImageParts(agent); got != 1 { + t.Fatalf("the running turn is left with %d pictures, want the 1 it started with", got) + } + + close(release) + collect(t, events) + + // Every stage this turn posted after the swap named the model the turn was + // on. One of them is the tool run released above. + said := false + for _, news := range phases.all() { + if news.Phase == "" { + continue + } + said = true + if news.Model != "test/model" { + t.Fatalf("a %q stage of the turn was posted under %q, want the model the turn is on", + news.Phase, news.Model) + } + } + if !said { + t.Fatal("the turn posted no stage at all, so the law was never exercised") + } + + // AND THE SWAP LANDS WHOLE AT THE NEXT TURN — the model on the wire and the + // scrub together, because the one place a next turn begins is where both + // happen ([Agent.startTurnLocked]). + collect(t, mustSubmit(t, agent, "again")) + if got := completer.model(2); got != "vendor/blind" { + t.Fatalf("the next turn rode %q, want vendor/blind", got) + } + if got := countImageParts(agent); got != 0 { + t.Fatalf("the turn on the blind model still carries %d pictures", got) + } + if got := agent.TurnModel(); got != "" { + t.Fatalf("an idle session claims a turn on %q", got) + } +} + +// An idle session's news names the dial, which is the honest answer to "what +// will the next request use" — and the emptiness law is about figures nobody +// has, not about this one. +func TestAnIdleSessionsNewsNamesTheDial(t *testing.T) { + agent, _ := newTestAgent(t, &scriptedCompleter{}, nil) + phases := watchPhases(t) + + agent.SetModel("vendor/next") + agent.tellPhase(provider.PhaseTidying, "", time.Time{}) + t.Cleanup(agent.endPhase) + + said := phases.all() + if len(said) == 0 { + t.Fatal("nothing was posted") + } + if got := said[len(said)-1].Model; got != "vendor/next" { + t.Fatalf("an idle session's news names %q, want the dial", got) + } +} + +// A TURN THAT SWAPPED NOTHING TAKES NOTHING AWAY. The scrub is one-way and the +// bytes do not come back, so it fires on a swap and on nothing else: an oracle +// that answers `sees` off a warm cache and `blind` a minute later off a catalog +// fetch that failed used to be enough to eat a person's screenshot with nobody +// having touched the picker. +func TestAFlappingOracleCannotScrubAConversationThatNeverSwapped(t *testing.T) { + sight := SightSees + completer := &scriptedCompleter{steps: []step{ + func(context.Context, []ai.Message) (*ai.Response, error) { return textResponse("first"), nil }, + func(context.Context, []ai.Message) (*ai.Response, error) { return textResponse("second"), nil }, + }} + agent, workspace := newTestAgent(t, completer, func(config *Config) { + config.SeesImages = func(string) ModelSight { return sight } + config.SessionFile = writeableJournal(t) + }) + path := writeImage(t, workspace, "chart.png", "PHOTOBYTES") + + ctx, cancel := deadline(20 * time.Second) + defer cancel() + collect(t, mustSubmitImage(t, agent, ctx, "what is wrong with this?", []Image{{Path: path}})) + if got := countImageParts(agent); got != 1 { + t.Fatalf("the picture never reached the transcript") + } + + // The catalog goes cold under the same model id. Nothing was swapped. + sight = SightBlind + collect(t, mustSubmit(t, agent, "and now?")) + if got := countImageParts(agent); got != 1 { + t.Fatalf("a turn on the SAME model took the pictures away: %d left", got) + } +} + +// AND AN UNKNOWN MODEL IS NOT A BLIND ONE. A swap onto a model this build has +// never read a row for is ignorance, and ignorance may refuse to send but may +// never destroy. +func TestASwapOntoAnUnknownModelKeepsThePictures(t *testing.T) { + completer := &scriptedCompleter{steps: []step{ + func(context.Context, []ai.Message) (*ai.Response, error) { return textResponse("first"), nil }, + func(context.Context, []ai.Message) (*ai.Response, error) { return textResponse("second"), nil }, + func(context.Context, []ai.Message) (*ai.Response, error) { return textResponse("third"), nil }, + }} + agent, workspace := newTestAgent(t, completer, func(config *Config) { + config.SeesImages = func(model string) ModelSight { + switch model { + case "test/model": + return SightSees + case "vendor/blind": + return SightBlind + } + return SightUnknown + } + config.SessionFile = writeableJournal(t) + }) + path := writeImage(t, workspace, "chart.png", "PHOTOBYTES") + + ctx, cancel := deadline(20 * time.Second) + defer cancel() + collect(t, mustSubmitImage(t, agent, ctx, "what is wrong with this?", []Image{{Path: path}})) + + agent.SetModel("vendor/never-heard-of") + collect(t, mustSubmit(t, agent, "still there?")) + if got := countImageParts(agent); got != 1 { + t.Fatalf("a swap onto a model nobody has vouched for destroyed %d pictures", 1-got) + } + + // AND A SWAP ONTO A MODEL THIS BUILD KNOWS IS BLIND STILL SCRUBS, which is + // the capability this guard must not have taken away. + agent.SetModel("vendor/blind") + collect(t, mustSubmit(t, agent, "and now?")) + if got := countImageParts(agent); got != 0 { + t.Fatalf("a swap onto a known-blind model left %d pictures", got) + } +} diff --git a/internal/session/vision_slot_test.go b/internal/session/vision_slot_test.go index f3274de50f..3a7b54697e 100644 --- a/internal/session/vision_slot_test.go +++ b/internal/session/vision_slot_test.go @@ -83,7 +83,7 @@ func TestWithNoResolverTheRolesLadderStillAnswers(t *testing.T) { func TestTheVisionFallbackRidesTheSlot(t *testing.T) { completer := &scriptedCompleter{} agent, workspace := newTestAgent(t, completer, func(config *Config) { - config.SupportsImages = func(model string) bool { return model == "vendor/slot-eyes" } + config.SeesImages = func(model string) ModelSight { return SightOf(model == "vendor/slot-eyes") } withSlot(map[string]string{"vision": "vendor/slot-eyes"})(config) }) @@ -114,7 +114,7 @@ func TestTheVisionFallbackRidesTheSlot(t *testing.T) { func TestASlotNamingTheBlindModelIsRefused(t *testing.T) { completer := &scriptedCompleter{} agent, workspace := newTestAgent(t, completer, func(config *Config) { - config.SupportsImages = func(string) bool { return false } + config.SeesImages = func(string) ModelSight { return SightOf(false) } withSlot(map[string]string{"vision": "test/model"})(config) }) diff --git a/internal/tui3/app.go b/internal/tui3/app.go index 6329bfc0ba..86b1662fb3 100644 --- a/internal/tui3/app.go +++ b/internal/tui3/app.go @@ -6724,11 +6724,15 @@ func (a *app) slash(line string) tea.Cmd { // a machine to pin, the word that un-pins, a question for the list, or // — still, and as the fall-through — a slug to switch to. switch intent, value := modelArg(rest); intent { + // THE PIN LANDS ON THE MODEL THE ENGINE IS ON, which is the model the + // chrome is naming ([app.wireModel]): `/lane` is read beside the chip + // that spells `model@lane`, and pinning the dial while another model is + // answering would put the instruction on a model nobody is looking at. case modelPinLane: - a.pinLane(a.model, value) + a.pinLane(a.wireModel(), value) return nil case modelAutoLane: - a.clearLanePin(a.model) + a.clearLanePin(a.wireModel()) return nil case modelQuery: a.openPickerFiltered(value) diff --git a/internal/tui3/effortchip.go b/internal/tui3/effortchip.go index 8b3be72826..b6e540e280 100644 --- a/internal/tui3/effortchip.go +++ b/internal/tui3/effortchip.go @@ -13,7 +13,7 @@ import ( // THE THINKING CHIP — how hard this conversation thinks, said beside the model // that is doing the thinking. // -// ─ porting the parser · glm-5.3-flash · ⠿ high · via deepinfra · main* ──── / commands ─ +// ─ porting the parser · glm-5.3-flash · ⠿ thinking high · via deepinfra · main* ── / commands ─ // › what changed in the relay this week // // internal/effort landed the ladder and internal/session landed the dial, and @@ -53,7 +53,7 @@ import ( // the number the machine is running at, so the chip is drawn from the resolver // and the scope that decided it is nobody's business up here. // -// ── AND ABSENCE IS A STATE, SO IT IS SAID: `⠿ auto` ──────────────────────── +// ── AND ABSENCE IS A STATE, SO IT IS SAID: `⠿ thinking auto` ─────────────── // // THE SHIPPED SETTING IS ABSENCE — [effort.Ship] is [effort.None], nothing is // asked for and the model thinks however it thinks — so on an install nobody @@ -66,9 +66,10 @@ import ( // be built. // // A CONTROL THAT IS INVISIBLE UNTIL YOU HAVE ALREADY USED IT IS NOT A CONTROL -// (CLAUDE.md's discoverability law). So absence is drawn, by name — `⠿ auto`, -// the word the `thinking` settings row has offered for this state since the -// ladder landed ([effortAutoWord]). +// (CLAUDE.md's discoverability law). So absence is drawn, by name — +// `⠿ thinking auto`, the ladder's own name ([effortLadderWord]) and the word the +// `thinking` settings row has offered for this state since the ladder landed +// ([effortAutoWord]). // // THIS IS NOT A BREACH OF THE EMPTINESS LAW. That law refuses a word for a // number nobody has — `$0.00`, `0 tok` — and auto is not zero thinking. It is a @@ -176,6 +177,11 @@ type effortDialer interface { // ResolvedEffort is the rung the next turn will actually ask for, whichever // scope decided it. ResolvedEffort() string + // ResolvedEffortFor is that rung FOR ONE MODEL ID, which is the question the + // seam's cell actually asks: the rung is drawn beside a model and must be + // that model's, and the levels the picker sets live per model id. An empty id + // is the dial, which is what an idle conversation's cell names. + ResolvedEffortFor(model string) string // ConversationEffort is the rung THIS conversation was set to, "" when // nobody has set one. ConversationEffort() string @@ -233,7 +239,7 @@ func (a *app) effortWord() string { // every mark on this surface. // // "" ONLY WHERE THERE IS NO DIAL, never where there is a dial nobody has turned: -// that one says `⠿ auto`, for the reason the header gives at length. The two +// that one says `⠿ thinking auto`, for the reason the header gives at length. The two // states were one string until 2026-09-09 and the cell was therefore missing on // every conversation of a shipped install, which is the whole defect. func (a *app) effortChipText() string { @@ -241,7 +247,13 @@ func (a *app) effortChipText() string { if !ok { return "" } - word := dial.ResolvedEffort() + // THE RUNG IS RESOLVED FOR THE MODEL THIS CELL SITS BESIDE, which is the + // model the engine is on and not the dial ([app.wireModel]). The levels the + // picker sets live per model id, so a person who dialled one model up and + // then picked another mid-turn had one model's name drawn next to the other + // model's level — and a fallback hop moved the turn's own rung (loop.go + // re-resolves for the model it hopped to) without moving the drawn one. + word := dial.ResolvedEffortFor(a.wireModel()) if word == "" { word = effortAutoWord } @@ -249,7 +261,7 @@ func (a *app) effortChipText() string { if a.pal.ascii || a.pal.linear { mark = glyphEffortASCII } - return mark + " " + word + return mark + " " + effortLadderWord + " " + word } // paintEffortChip is the chip's one cell of colour, in the two states that are diff --git a/internal/tui3/effortchip_test.go b/internal/tui3/effortchip_test.go index 24724a0788..8251857667 100644 --- a/internal/tui3/effortchip_test.go +++ b/internal/tui3/effortchip_test.go @@ -35,6 +35,10 @@ type effortAgent struct { turn effort.Rung // installed is the install's own rung — the `effort` settings row. installed effort.Rung + // perModel is a rung dialled onto ONE model id, which is how a person's + // ctrl+t in the picker stores one. It is what makes the seam's cell able to + // lie: the rung drawn beside a model must be that model's. + perModel map[string]effort.Rung // sets is every word the surface handed to SetConversationEffort, in order, // refusals included: what the chord WROTE is a different question from what // the resolver then answered. @@ -43,9 +47,20 @@ type effortAgent struct { func (e *effortAgent) ConversationEffort() string { return e.conversation.String() } -func (e *effortAgent) ResolvedEffort() string { +func (e *effortAgent) ResolvedEffort() string { return e.ResolvedEffortFor("") } + +// ResolvedEffortFor is that rung for ONE MODEL ID, which is what the seam's cell +// asks for: the levels a picker sets live per model id, so the rung drawn beside +// a model has to be that model's. `perModel` is those levels; an id with none +// falls through to the conversation's scope exactly as the engine's resolver +// does (internal/session's [Agent.effortFor]). +func (e *effortAgent) ResolvedEffortFor(model string) string { + turn := e.turn + if rung, ok := e.perModel[model]; ok { + turn = rung + } return effort.Resolve(effort.Scope{ - Turn: e.turn, + Turn: turn, Conversation: e.conversation, Default: e.installed, }).String() @@ -132,7 +147,7 @@ func TestTheSeamNamesTheResolvedThinkingRung(t *testing.T) { t.Fatalf("the session started with a chosen rung: %q", got) } line := seamLine(t, a) - if !strings.Contains(line, "deepseek-v4 · "+glyphEffort+" high") { + if !strings.Contains(line, "deepseek-v4 · "+glyphEffort+" "+effortLadderWord+" high") { t.Fatalf("the seam does not name the configured rung beside the model: %q", line) } // AND THE COLON SPELLING IS GONE. The level used to ride the model id — @@ -145,7 +160,7 @@ func TestTheSeamNamesTheResolvedThinkingRung(t *testing.T) { // And it follows the resolver rather than remembering anything: a rung set on // the conversation moves the word on the next frame. agent.conversation = effort.Max - if line := seamLine(t, a); !strings.Contains(line, glyphEffort+" max") { + if line := seamLine(t, a); !strings.Contains(line, glyphEffort+" "+effortLadderWord+" max") { t.Fatalf("the seam kept the old rung: %q", line) } } @@ -164,7 +179,7 @@ func TestAFreshConversationSaysAutoAndIsPressable(t *testing.T) { t.Fatalf("the shipped session resolved to %q, want absence", got) } line := seamLine(t, a) - if !strings.Contains(line, "deepseek-v4 · "+glyphEffort+" "+effortAutoWord) { + if !strings.Contains(line, "deepseek-v4 · "+glyphEffort+" "+effortLadderWord+" "+effortAutoWord) { t.Fatalf("a fresh conversation does not say auto beside the model: %q", line) } if !a.seamEffortSpan.pressable() { @@ -176,7 +191,7 @@ func TestAFreshConversationSaysAutoAndIsPressable(t *testing.T) { if got := agent.ConversationEffort(); got != "low" { t.Fatalf("the first press off auto left the conversation at %q, want low", got) } - if line := seamLine(t, a); !strings.Contains(line, glyphEffort+" low") { + if line := seamLine(t, a); !strings.Contains(line, glyphEffort+" "+effortLadderWord+" low") { t.Fatalf("the chord did not walk the cell onto low: %q", line) } } @@ -189,7 +204,7 @@ func TestEffortAutoPutsTheCellBackToAuto(t *testing.T) { for _, word := range []string{"auto", "off"} { agent, a := shipped(t) a.slash("/effort high") - if line := seamLine(t, a); !strings.Contains(line, glyphEffort+" high") { + if line := seamLine(t, a); !strings.Contains(line, glyphEffort+" "+effortLadderWord+" high") { t.Fatalf("/effort high did not reach the seam: %q", line) } @@ -197,7 +212,7 @@ func TestEffortAutoPutsTheCellBackToAuto(t *testing.T) { if got := agent.ConversationEffort(); got != "" { t.Fatalf("/effort %s left the conversation at %q, want absence", word, got) } - if line := seamLine(t, a); !strings.Contains(line, glyphEffort+" "+effortAutoWord) { + if line := seamLine(t, a); !strings.Contains(line, glyphEffort+" "+effortLadderWord+" "+effortAutoWord) { t.Fatalf("/effort %s did not put the cell back to auto: %q", word, line) } // It says what now decides, because `auto` on the seam reads like the dial @@ -247,7 +262,7 @@ func TestAHostedConversationDrawsTheRungItsEngineAdmitsTo(t *testing.T) { a.model, a.title = "deepseek/deepseek-v4", "porting the parser" line := seamLine(t, a) - if drew := strings.Contains(line, glyphEffort+" high"); drew != known { + if drew := strings.Contains(line, glyphEffort+" "+effortLadderWord+" high"); drew != known { t.Fatalf("an engine that says known=%v drew rung=%v: %q", known, drew, line) } drive(t, a, key(effortKey)) @@ -269,7 +284,7 @@ func TestTheRungKeepsItsColumnsWhenTheRiderComesAndGoes(t *testing.T) { }, true) served := seamLine(t, a) - if !strings.Contains(served, glyphEffort+" high · via quicksilver") { + if !strings.Contains(served, glyphEffort+" "+effortLadderWord+" high · via quicksilver") { t.Fatalf("the rider does not follow the rung: %q", served) } if strings.Index(bare, glyphEffort) != strings.Index(served, glyphEffort) { @@ -289,7 +304,7 @@ func TestANarrowSeamDropsTheRungRatherThanCuttingIt(t *testing.T) { } found := false for _, rung := range effort.Rungs { - if strings.Contains(line, glyphEffort+" "+rung.String()) { + if strings.Contains(line, glyphEffort+" "+effortLadderWord+" "+rung.String()) { found = true } } @@ -314,7 +329,7 @@ func TestCtrlVCyclesTheConversationRungAndWraps(t *testing.T) { if got := agent.ConversationEffort(); got != rung { t.Fatalf("press %d left the conversation at %q, want %q", at+1, got, rung) } - if line := seamLine(t, a); !strings.Contains(line, glyphEffort+" "+rung) { + if line := seamLine(t, a); !strings.Contains(line, glyphEffort+" "+effortLadderWord+" "+rung) { t.Fatalf("press %d drew %q, want %q", at+1, line, rung) } } @@ -607,7 +622,7 @@ func TestPickingTheLaddersTopRowClearsTheRung(t *testing.T) { if got := agent.ConversationEffort(); got != "" { t.Fatalf("the top row left the conversation at %q, want absence", got) } - if line := seamLine(t, a); !strings.Contains(line, glyphEffort+" "+effortAutoWord) { + if line := seamLine(t, a); !strings.Contains(line, glyphEffort+" "+effortLadderWord+" "+effortAutoWord) { t.Fatalf("the seam did not come back to auto: %q", line) } } diff --git a/internal/tui3/effortscope.go b/internal/tui3/effortscope.go index d676e52b94..136c3a226e 100644 --- a/internal/tui3/effortscope.go +++ b/internal/tui3/effortscope.go @@ -67,11 +67,36 @@ import ( "github.com/Agent-Field/aforge-v2/internal/effort" ) -// effortClauseWord leads the clause on every surface. It is the word the -// settings row already labels this setting with (internal/config's settings.go -// spells the row `thinking`), so the fact a card states and the row a person -// finds in the panel are the same word rather than two. -const effortClauseWord = "thinking " +// effortLadderWord is WHAT THE RUNG IS ABOUT, and the one place this surface +// spells it. It is the word the settings row already labels this setting with +// (internal/config's settings.go spells the row `thinking`), so the fact a card +// states, the row a person finds in the panel and the cell on the seam are the +// same word rather than three. +// +// A RUNG WORD ON ITS OWN IS A WORD BESIDE A MODEL, AND `auto` IS ALSO THE LANE +// VOCABULARY'S WORD. `/model auto` hands the choice of machine back to the +// router, the `lane` row in /settings reads `auto`, and the manual's own +// settings picture spells `lane auto` two lines under `your model`. So +// `glm-5.3-flash · ⠿ auto` had two honest readings and the owner took the wrong +// one TWICE — once on 2026-09-10, when the pin looked to them as though it had +// failed and the `model@lane` spelling was built in answer ([app.modelWord]), +// and again on 2026-09-11 on `moonshotai/kimi-k3 · auto · via wafer`. A cell +// that is read wrong twice is not a cell somebody else should be asked to read +// a third time. +// +// SO THE CELL NAMES ITS OWN LADDER, ALWAYS, AND NOT ONLY FOR THE WORD THAT +// COLLIDES. A cell that qualified itself for `auto` and went bare for `high` +// would be a rule about one word rather than about the cell, and the next rung +// word to collide with something would be read wrong too. `⠿ thinking auto`, +// `⠿ thinking high`: the same sentence at every rung, and the width the extra +// word costs is spent by the seam's own ladder, which drops this cell WHOLE when +// the line is tight (foot.go's [seamLadder]). +const effortLadderWord = "thinking" + +// effortClauseWord leads the clause on every surface — the ladder's word with +// the space that separates it from the rung. It is derived rather than spelled +// again, because a number (or a word) that appears in two places will drift. +const effortClauseWord = effortLadderWord + " " // effortKeyClause is what a legend says beside it, in the hint grammar every // other legend on this surface is written in — `chord verb` (payload.go). diff --git a/internal/tui3/foot.go b/internal/tui3/foot.go index 280442f577..53ddaf2307 100644 --- a/internal/tui3/foot.go +++ b/internal/tui3/foot.go @@ -420,7 +420,7 @@ func (a *app) runStatusNote() tea.Cmd { // seamIdentity is the legend's left cluster out of a room, built to a budget, // and the columns its two doors occupy within it. // -// devbox · porting the parser · glm-5.3-flash · ⠿ high · via deepinfra · main* +// devbox · porting the parser · glm-5.3-flash · ⠿ thinking high · via deepinfra · main* // // THE LADDER IS [seamLadder], read top to bottom, and it never clips. // diff --git a/internal/tui3/imagepaste_test.go b/internal/tui3/imagepaste_test.go index ae61ab628e..ada5ad3866 100644 --- a/internal/tui3/imagepaste_test.go +++ b/internal/tui3/imagepaste_test.go @@ -449,12 +449,12 @@ func TestADroppedPicturesTokenAndBytesBothReachTheWire(t *testing.T) { } agent, err := session.New(session.Config{ - Workspace: dir, - Model: "vendor/sees", - APIKey: "test", - BaseURL: server.URL, - System: "SYSTEM", - SupportsImages: func(string) bool { return true }, + Workspace: dir, + Model: "vendor/sees", + APIKey: "test", + BaseURL: server.URL, + System: "SYSTEM", + SeesImages: func(string) session.ModelSight { return session.SightOf(true) }, }) if err != nil { t.Fatalf("session.New: %v", err) diff --git a/internal/tui3/lanes.go b/internal/tui3/lanes.go index 7405f45b38..d589ef0983 100644 --- a/internal/tui3/lanes.go +++ b/internal/tui3/lanes.go @@ -1197,10 +1197,108 @@ const laneAtSign = "@" // lane at all, [app.routingOff]), and over a connection, where the pin in force // is the far machine's and this process cannot see it. func (a *app) pinnedNow() string { - if a.hosted() || a.routingOff || a.model == "" || a.modelIsDirect(a.model) { + model := a.wireModel() + if a.hosted() || a.routingOff || model == "" || a.modelIsDirect(model) { return "" } - return strings.ToLower(provider.PinnedFor(a.model)) + return strings.ToLower(provider.PinnedFor(model)) +} + +// wireModel is THE MODEL THE ENGINE IS ON: the model of the request in flight +// while there is one, and the dial when there is not. +// +// ── THE CHROME NAMES THE MODEL THE ENGINE IS ON ───────────────────────────── +// +// This is the law, and one line of one reported session is the whole argument +// for it. A person sent a message, opened the picker three seconds later and +// chose kimi-k3. The turn had already latched glm-5.3-flash and ran there for +// seventeen minutes and forty-one calls — which is what [session.Agent.SetModel] +// promises and what the engine did. The seam said +// `moonshotai/kimi-k3 · auto · via wafer` for all of it: the model cell drawn +// from [app.model], the picker's answer, and the `via` cell drawn from the +// endpoint the request in flight had named. Two cells, two worlds, one line, and +// neither cell wrong by its own reckoning. +// +// So both cells now come off ONE fact: [session.Agent.TurnModel], the model the +// engine latched when this turn opened. It is the same fact the engine stamps on +// every stage of the work it is doing ([session.Agent.newsModel]), so the `via` +// machine and the name above it are two readings of one turn — and it crosses a +// --host connection on the photograph the rung beside it already rides +// (internal/remote's turnmodel.go), so there is no second road to keep alive. +// +// AND IT FALLS BACK TO THE DIAL RATHER THAN TO NOTHING. An idle conversation has +// no request in flight and the honest word for it is the model the next one will +// use, which is what a person just picked; the emptiness law is about figures +// nobody has, and this is a figure everybody has. +func (a *app) wireModel() string { + if model := a.turnModel(); model != "" { + return model + } + return a.model +} + +// turnModel is [session.Agent.TurnModel] as it reaches this surface: the model +// the turn in flight is on, and "" when nothing is in flight or when nothing at +// the far end keeps the fact. +// +// IT IS THE ENGINE'S OWN FACT AND NOT A READING OF THE NEWS DESK. The first cut +// of this took a live phase as proof that a turn was running and read the model +// off it, which is wrong twice over: a phase entry is DELETED at the end of +// every request and at the end of every tool batch (internal/provider's +// [phaseClock.done], internal/session's [Agent.endPhase]), so between two steps +// of one turn the desk is empty and the seam would fall back to the dial — the +// cell flipping back and forth across every step boundary — and a pick that +// landed in one of those gaps would find nothing to say `finishes on` about. The +// latch has none of those holes: it is set when the turn opens and stands until +// the turn ends. +// +// IT IS GATED ON THE SURFACE'S OWN STATE, never on a desk entry. `stateWorking` +// is this window's answer to "is a turn in flight", kept by the stream it is +// reading, and it is the same answer the composer's own hints are drawn from. +// +// AND A FAR ENGINE THAT DOES NOT KEEP THE FACT ANSWERS NOTHING, by the door it +// stated at the welcome ([remote.Agent.TurnModelSupported]) — not by the empty +// string, which is a real answer here. A capability that cannot work is absent +// rather than broken: the seam names the dial, exactly as it did before this +// existed, and the switch says nothing about a turn in flight. +// +// It is [app.wireModel] WITHOUT the fallback, and the two are kept apart for the +// one caller that has to tell the difference: a switch mid-turn says what it did +// not touch ([app.switchModel]), and a fallback to the dial would answer that +// question with the model the person just picked — which is the sentence being +// nothing at all. +func (a *app) turnModel() string { + if a.state != stateWorking { + return "" + } + door, ok := a.turnModelDoor() + if !ok { + return "" + } + return strings.TrimSpace(door.TurnModel()) +} + +// turnModelDoor is the slice of the session that names the model a turn is on, +// and false where there is none. +// +// It is asserted on the agent rather than added to [Agent] on [effortDialer]'s +// terms: every scripted agent in this package's own tests is an [Agent], and a +// method added to that interface is a method thirty test doubles have to grow +// before a model can be drawn. +// +// AND A CONNECTION ANSWERS FOR THE MACHINE AT THE OTHER END, exactly as it does +// for the rung ([app.effortDial] states the whole argument): a *remote.Agent +// always has the method, so the assertion alone says yes for every `--host` +// session whatever the far engine can do. +func (a *app) turnModelDoor() (interface{ TurnModel() string }, bool) { + if a.agent == nil { + return nil, false + } + door, ok := a.agent.(interface{ TurnModel() string }) + if host, hosted := a.agent.(interface{ TurnModelSupported() bool }); hosted { + ok = ok && host.TurnModelSupported() + } + return door, ok } // modelWord is THE MODEL AS THE CHROME NAMES IT: its basename ([modelBase]), @@ -1209,9 +1307,11 @@ func (a *app) pinnedNow() string { // A PIN IS AN INSTRUCTION THAT CHANGES EVERY FUTURE REQUEST, and until this the // chrome never said it. The picker's row said `via inception` only while the // picker was open, the status rider said `via …` only for ten minutes after an -// answer, and the frame's head read `mercury-2.5 · ⠿ auto` — whose `auto` is the +// answer, and the frame's head read `mercury-2.5 · ⠿ auto` — whose `auto` was the // thinking rung, which the owner read as "lane: auto" and concluded the pin had -// failed (2026-09-10). So the pin rides the one word every place that names the +// failed (2026-09-10). That rung cell now names its own ladder +// ([effortLadderWord]), so the collision it caused is gone from the other side +// too; this half stands because a pin is still a fact the chrome owes a person. So the pin rides the one word every place that names the // model already draws: the seam, the status row's identity and the phone deck's // chip all take it from here, and none of them spells it. // @@ -1230,9 +1330,15 @@ func (a *app) modelWord() string { return a.modelWordAt("") } // the model by a name nothing is filed under — which is how a person who had set // a level lost the live rate from the right edge of the row. func (a *app) modelWordAt(level string) string { - model := modelBase(a.model) + // THE ID IS THE ONE THE ENGINE IS ON and not the one the picker last set + // ([app.wireModel] is the law and the reported line it came from). Every + // place the chrome names this conversation's model takes its word from here + // — the seam, the status row, the phone deck's chip — so there is one answer + // on the frame however many cells spell it. + id := a.wireModel() + model := modelBase(id) if !a.sources.Empty() { - service, bare := a.sources.For(a.model) + service, bare := a.sources.For(id) model = service.Qualify(bare) } if model == "" { @@ -1260,7 +1366,10 @@ func (a *app) openPickerFromChip() { // THE FOLD HAS TO BE THE ONE THE CHIP NAMES, for [app.openLaneList]'s // reason: a model the list does not carry leaves the cursor on row zero, // and unfolding whatever sorted first would open somebody else's machines. - if chosen, ok := a.pick.choice(); ok && chosen.ID == a.model { + // AND THE MODEL IT IS ABOUT IS THE ONE THE CHIP SPELLS, which is the model the + // engine is on ([app.wireModel]) — hover.go's law: what lights is what the + // press acts on, and the press must land on the machine the word names. + if chosen, ok := a.pick.choice(); ok && strings.EqualFold(chosen.ID, a.wireModel()) { a.pick.unfoldHere() } } @@ -1321,7 +1430,11 @@ func (a *app) openPickerFromChip() { // a machine the address already names is not said twice. The seam asks // [app.talkLaneRider] instead, which never suppresses. func (a *app) laneRider(timed bool) string { - return a.laneRiderFor(a.talkLaneStory(), a.model, "", timed, a.state == stateWorking) + // THE ID IT SPELLS IN FULL IS THE MODEL THE ENGINE IS ON, because that is the + // id the row beside it names ([app.wireModel]) and this one is read for + // exactly one thing — not saying a machine the id already carries twice. Read + // off the dial it would suppress against a name nobody drew. + return a.laneRiderFor(a.talkLaneStory(), a.wireModel(), "", timed, a.state == stateWorking) } // talkLaneRider is the conversation's rider ON THE SEAM: who is answering, with diff --git a/internal/tui3/palette.go b/internal/tui3/palette.go index b1cf2e1bef..7841d1c257 100644 --- a/internal/tui3/palette.go +++ b/internal/tui3/palette.go @@ -1740,11 +1740,17 @@ func (p *picker) keysHint() string { // any other, and every slot says which models may answer it (settings.go's // [filterFor]). func (a *app) openPicker() { - a.pick.startFor(a.modelList(), a.model, chatModel) + // IT OPENS ON THE MODEL THE ENGINE IS ON, which is the model the chip that + // opened it was spelling ([app.wireModel]) — hover.go's law: what lights is + // what the press acts on. A picker that opened with the cursor on the dial + // after a mid-turn pick would be a list about a model the person is not + // looking at. + wire := a.wireModel() + a.pick.startFor(a.modelList(), wire, chatModel) // THE PIN IS A SNAPSHOT, exactly as the model in use is: it is what marks a // row inside an open fold, and what the row in use says `via`, and neither // of those can change while a modal overlay owns the keyboard. - a.armLanes(&a.pick, laneSlotFor(a.model)) + a.armLanes(&a.pick, laneSlotFor(wire)) a.armRefresh() a.touch() } @@ -1914,6 +1920,13 @@ func (a *app) windowFor(id string) int { // fires at a fraction of it, so a session that switched to a 1M model without // saying so would keep compacting as if it were on the 128k one it started on. func (a *app) switchModel(id string, window int) { + // WHAT THE TURN IN FLIGHT IS ON, READ BEFORE THE DIAL MOVES. It is the + // engine's own answer and not a flag this surface keeps — [app.turnModel] is + // [session.Agent.TurnModel] as it crossed the seam — so a model named here IS + // the engine saying there is work in flight and what it is talking to. It is + // read first because the dial is about to become the answer to a different + // question. + turnOn := a.turnModel() a.agent.SetModel(id) a.model = a.agent.Model() if a.model == "" { @@ -1946,11 +1959,31 @@ func (a *app) switchModel(id string, window int) { // started after this line follow the switch (taskmodel.go's // defaultTaskModel reads the live dial), which is why the sentence is // about the running ones only. + // AND THE TURN IN FLIGHT IS THE FIRST THING IT SAYS, because it is the thing + // the person is watching. [session.Agent.SetModel] latches a turn's model + // when the turn opens and never moves it, so a swap made three seconds into + // a turn changes nothing about the seventeen minutes that follow — and until + // this line the surface said nothing at all about that, while drawing the + // model just picked on the seam beside the old model's endpoint. The sentence + // is derived from what the engine is on rather than from a flag: if the + // engine is naming a model, there is work on it, and if that model is the one + // just picked there is nothing to say. note := "model · " + a.model + if turnOn != "" && !strings.EqualFold(turnOn, a.model) { + note += " — this turn finishes on " + turnOn + } if a.deckRunning() > 0 { note += " — tasks already running keep the model they started on" } - a.noteFacts(note, a.model) + // BOTH IDS ON THE LINE STEP TO INK. The id just picked is the fact this note + // is about; the model the turn keeps is the fact it is about NEXT, and a + // sentence that dimmed half of what it was telling somebody would be a + // sentence half read (payload.go). + facts := []string{a.model} + if turnOn != "" { + facts = append(facts, turnOn) + } + a.noteFacts(note, facts...) a.noticeEvent(eventModelSwitched) } diff --git a/internal/tui3/render.go b/internal/tui3/render.go index 69934da9da..d21400e08f 100644 --- a/internal/tui3/render.go +++ b/internal/tui3/render.go @@ -1557,8 +1557,12 @@ func (a *app) waitingWords() string { default: word = retryWord } - case modelBase(a.model) != "": - word = waitForWord + modelBase(a.model) + // THE WAIT NAMES THE MODEL BEING WAITED ON, which is the model the engine is + // on and not the one the picker last set ([app.wireModel]). `waiting for + // kimi-k3` under a seam reading `glm-5.3-flash` would be two models named on + // one screen about one wait. + case modelBase(a.wireModel()) != "": + word = waitForWord + modelBase(a.wireModel()) } if clock := countUpWord(waited); clock != "" { word += " · " + clock @@ -2429,7 +2433,12 @@ func (a *app) servedRiderAt(width int) string { if rider := a.laneRider(true); rider != "" { return rider } - sighting, ok := servedSighting(a.model) + // THE SIGHTING IS LOOKED UP UNDER THE MODEL THE ENGINE IS ON, because that is + // the model this row names ([app.wireModel]) and the desk is keyed by model: + // asking under the dial after a mid-turn pick finds the wrong machine, or no + // machine at all, for the name drawn beside it. + wire := a.wireModel() + sighting, ok := servedSighting(wire) if !ok || sighting.Provider == "" { return "" } @@ -2437,7 +2446,7 @@ func (a *app) servedRiderAt(width int) string { return "" } served := strings.ToLower(sighting.Provider) - if strings.Contains(strings.ToLower(a.model), served) { + if strings.Contains(strings.ToLower(wire), served) { return "" } // The name leads and the rate is the field after it, which is this segment's @@ -2503,7 +2512,8 @@ func (a *app) modelRiderAt(width int) string { } return "" } - sighting, ok := servedSighting(a.model) + // Under the model the engine is on, for [app.servedRiderAt]'s reason. + sighting, ok := servedSighting(a.wireModel()) if !ok || sighting.Provider == "" { return "" } diff --git a/internal/tui3/statusdeck.go b/internal/tui3/statusdeck.go index 01f04e86c9..01b415532d 100644 --- a/internal/tui3/statusdeck.go +++ b/internal/tui3/statusdeck.go @@ -152,7 +152,12 @@ func (a *app) deckTopRow(width int) string { // (view.go's [app.statusRow] tells that story). func (a *app) deckModelRow(width int) string { right, plainRight := a.deckAmbient(width) - chip := a.modelWordAt(a.reasoningFor(a.model)) + // AND THE LEVEL IS THE ONE SET ON THE MODEL THE WORD NAMES. The word is the + // model the engine is on ([app.wireModel]), so asking the dial's model for a + // level would spell one model's rung onto another model's name the moment + // somebody picks a model mid-turn — the levels are kept per model id + // (tui3.go's [Agent.ReasoningFor]), so the two really can differ. + chip := a.modelWordAt(a.reasoningFor(a.wireModel())) // A ROOM RENAMES THIS ROW TOO, which is the wide row's own law at phone width // (render.go's [app.identityParts]): row 1 has already renamed itself to the // task, and a row 2 still naming the session's model would be the deck's half @@ -424,8 +429,12 @@ func (a *app) deckItems() []deckItem { // The model is its FULL routing address here, not its basename: the sheet is // where the thing is recorded and where it is chosen, which is exactly where // [app.identity] says the whole id belongs. - model := a.model - if level := a.reasoningFor(a.model); level != "" && model != "" { + // AND IT IS THE MODEL THE ENGINE IS ON, WITH ITS OWN LEVEL. The `lane` row + // directly below is looked up from the same id ([app.pinnedNow]), so the two + // are one reading and cannot name two models — which is what they did while + // this row read the dial and that one read the wire. + model := a.wireModel() + if level := a.reasoningFor(model); level != "" && model != "" { model += ":" + level } add("model", model, deckActModel) diff --git a/internal/tui3/tui3_test.go b/internal/tui3/tui3_test.go index 4a66d5bfb1..bcfb2137cb 100644 --- a/internal/tui3/tui3_test.go +++ b/internal/tui3/tui3_test.go @@ -127,14 +127,18 @@ func runTests(m *testing.M) int { // reason [Agent] is an interface — the surface is driven without a provider, a // key, or a file. type fakeAgent struct { - turns [][]session.Event - turn int - live chan session.Event - model string - window int - usage session.Usage - sent []string - stops int + // turnModel is what [fakeAgent.TurnModel] answers: the model a turn in + // flight is on. Empty is an idle session, which is what almost every test + // here wants. + turnModel string + turns [][]session.Event + turn int + live chan session.Event + model string + window int + usage session.Usage + sent []string + stops int // stopDoor is the door the last stop named (internal/session's stopcause.go). stopDoor session.StopDoor closes int @@ -261,9 +265,15 @@ func (f *fakeAgent) InterruptFor(door session.StopDoor) { f.stopDoor = door f.stops++ } -func (f *fakeAgent) Compact(context.Context) error { f.packs++; return nil } -func (f *fakeAgent) Close() error { f.closes++; return nil } -func (f *fakeAgent) Model() string { return f.model } +func (f *fakeAgent) Compact(context.Context) error { f.packs++; return nil } +func (f *fakeAgent) Close() error { f.closes++; return nil } +func (f *fakeAgent) Model() string { return f.model } + +// TurnModel is the latch a local session keeps: the model the turn in flight is +// on, "" when nothing is in flight ([session.Agent.TurnModel]). A double that did +// not have it would leave the surface with no door at all, which is a different +// state from an idle conversation and would hide every wire-model law. +func (f *fakeAgent) TurnModel() string { return f.turnModel } func (f *fakeAgent) SetModel(model string) { f.model = model } func (f *fakeAgent) SetContextWindow(tokens int) { f.window = tokens } func (f *fakeAgent) ReasoningFor(model string) string { return f.levels[model] } diff --git a/internal/tui3/wiremodel_law_test.go b/internal/tui3/wiremodel_law_test.go new file mode 100644 index 0000000000..9882825583 --- /dev/null +++ b/internal/tui3/wiremodel_law_test.go @@ -0,0 +1,211 @@ +package tui3 + +// THE LAW: WHAT IS DRAWN BESIDE THE MODEL CELL READS THE MODEL THE ENGINE IS ON. +// +// `a.model` is THE DIAL — what the person last picked, what the next turn will +// use. [app.wireModel] is what the engine is on right now. The two part company +// the moment somebody picks a model while a turn is running, and on 2026-09-11 a +// person read the two answers side by side for seventeen minutes: the model cell +// drawn from the dial, the `via` machine drawn from the request in flight, +// neither cell wrong on its own terms. +// +// So this is a RATCHET over the whole package rather than a rule about one +// file. Every function that reads the dial is named below with the reason it is +// allowed to; a reader that appears without being named fails, which is the only +// moment anybody is going to ask the question this law exists for — is this +// about what the person chose, or about what the machine is doing? Both are +// legitimate answers and they are not interchangeable, and the ones that got it +// wrong had all been written by people who never knew there were two. + +import ( + "go/ast" + "go/parser" + "go/token" + "os" + "strings" + "testing" +) + +// dialReaders are the functions that may read `a.model`, each because it is +// genuinely about WHAT THE PERSON CHOSE rather than about what is answering. +// +// Four groups, and the group is the reason: +// +// - THE DIAL AS A CONTROL. The settings sheet, the picker's rows and filters, +// the lane list, the composer's own label, the welcome line: every one of +// these draws or edits the choice itself, and a control that showed the +// model mid-flight would refuse to let a person see what they had set. +// - THE DIAL AS STATE BEING MOVED. Opening, resuming, switching, adopting a +// model a service handed back: these WRITE the field or read it to write it. +// - THE CONVERSATION'S OWN BOOKKEEPING. Cost, the context meter, the +// compaction threshold, the price cache, the desk key: all of them are about +// the conversation as a whole and are keyed by the model it belongs to. +// - THE DOOR ITSELF. [app.wireModel] reads the dial as its idle fallback, +// which is the one reading that makes every other cell honest. +var dialReaders = map[string]string{ + // the dial as a control + "setupControlsForm": "the settings sheet draws the choice itself", + "setupModelChoices": "the settings sheet's own rows", + "setupModelDetail": "the settings sheet's own detail line", + "takeSetupModel": "the settings sheet taking a pick", + "filterSetupModels": "the settings sheet's list, filtered against the choice", + "raiseSettings": "opening the sheet on the row in force", + "pickerKey": "the picker's own keystroke, about the row it opened on", + "welcomeModelLine": "the opening line says what this conversation was started on", + "composerModel": "the composer's label is the choice, not the flight", + "talkingTo": "the words `you talk to` name the choice", + "targetModel": "home's next-conversation target, which has no turn at all", + "targetModelPinned": "the same target's pin", + "openLaneList": "the lane list is about the model whose lanes are being chosen", + "cycleLane": "walking that list", + "laneRowChanged": "the person's own act on the lane row", + "setEffortRung": "the rung is stored per model id, and this sets it", + "nonChatWarning": "the refusal names the model that would have been switched to", + "disconnectModelService": "a service being disconnected, about the chosen model", + "connectedServiceCarriesModel": "whether the choice belongs to the service just connected", + + // the dial as state being moved + "newApp": "the field's own opening value", + "applyEvent": "the engine handing the dial back", + "attachConversation": "taking over a conversation brings its dial", + "switchModel": "this is the road the dial moves along", + "adoptModelConnectResult": "a connect flow handing back a model to switch to", + "moveConversationToConnectedModel": "the same move, from the connect card", + + // the conversation's own bookkeeping + "measureContext": "the context meter is the conversation's, keyed by its model", + "ctxHeat": "the meter's colour, from the same reading", + "ctxSpark": "the meter's sparkline, from the same reading", + "compactionETA": "the threshold is a fraction of the chosen model's window", + "compactionRuleWord": "the sentence about that threshold", + "cacheNote": "the price cache is keyed by the chosen model", + "repriceCache": "the same cache", + "registry": "the settings registry's rows", + "readWorldKnown": "the world reading this window was opened with", + "taskSheetSelfRow": "the conversation's own row on the task sheet", + "talkKeys": "the news desk's LEGACY fallback key, for news that names no conversation", + + // the door itself + "wireModel": "the idle fallback, which is what makes every other cell honest", +} + +// forEachPackageFile parses every non-test file of this package. +func forEachPackageFile(t *testing.T, visit func(path string, file *ast.File, fset *token.FileSet)) { + t.Helper() + entries, err := os.ReadDir(".") + if err != nil { + t.Fatalf("reading the package: %v", err) + } + fset := token.NewFileSet() + scanned := 0 + for _, entry := range entries { + name := entry.Name() + if entry.IsDir() || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") { + continue + } + parsed, err := parser.ParseFile(fset, name, nil, parser.ParseComments) + if err != nil { + t.Fatalf("%s: %v", name, err) + } + scanned++ + visit(name, parsed, fset) + } + if scanned < 100 { + t.Fatalf("only %d files were scanned; this package holds far more, so the walk is broken rather than clean", scanned) + } +} + +func TestEveryDialReaderIsOneThatMeansTheDial(t *testing.T) { + seen := map[string]bool{} + forEachPackageFile(t, func(path string, file *ast.File, fset *token.FileSet) { + for _, decl := range file.Decls { + fn, ok := decl.(*ast.FuncDecl) + if !ok || fn.Body == nil { + continue + } + ast.Inspect(fn.Body, func(node ast.Node) bool { + selector, ok := node.(*ast.SelectorExpr) + if !ok || selector.Sel.Name != "model" { + return true + } + if ident, ok := selector.X.(*ast.Ident); !ok || ident.Name != "a" { + return true + } + seen[fn.Name.Name] = true + if _, allowed := dialReaders[fn.Name.Name]; !allowed { + t.Errorf("%s reads the dial `a.model` at %s.\n"+ + " The dial is what the person PICKED; app.wireModel() is what the engine is ON,\n"+ + " and anything drawn beside the model cell must read the second or the frame\n"+ + " names two models at once. If this really is about the choice, add it to\n"+ + " dialReaders with the reason.", + fn.Name.Name, fset.Position(selector.Pos())) + } + return true + }) + } + }) + // AND THE LIST ONLY EVER SHRINKS. A name left on it after its reader moved to + // [app.wireModel] is permission nobody is using, and the next reader to be + // written under that name would inherit it silently. + for name := range dialReaders { + if !seen[name] { + t.Errorf("dialReaders still allows %q, which no longer reads the dial — delete the line", name) + } + } +} + +// AND THE WIRE READING ITSELF KEEPS ITS TWO HALVES. [app.turnModel] must ask the +// engine and must never infer a turn from the news desk: a phase entry is +// deleted at the end of every request and every tool batch, so a reading taken +// from its presence goes empty between two steps of one turn — the cell flipping +// back to the dial at every step boundary, which is the defect wearing a +// different hat. +func TestTheWireReadingAsksTheEngineAndNotTheNewsDesk(t *testing.T) { + source, err := os.ReadFile("lanes.go") + if err != nil { + t.Fatal(err) + } + fset := token.NewFileSet() + file, err := parser.ParseFile(fset, "lanes.go", source, 0) + if err != nil { + t.Fatal(err) + } + found := false + for _, decl := range file.Decls { + fn, ok := decl.(*ast.FuncDecl) + if !ok || fn.Body == nil || fn.Name.Name != "turnModel" { + continue + } + found = true + asksEngine, readsDesk := false, false + ast.Inspect(fn.Body, func(node ast.Node) bool { + call, ok := node.(*ast.CallExpr) + if !ok { + return true + } + switch fun := call.Fun.(type) { + case *ast.SelectorExpr: + switch fun.Sel.Name { + case "TurnModel": + asksEngine = true + case "livePhase", "talkPhase", "roomPhase": + readsDesk = true + } + case *ast.Ident: + if fun.Name == "phaseNewsFor" { + readsDesk = true + } + } + return true + }) + if !asksEngine { + t.Error("turnModel no longer asks the engine for its latched model") + } + if readsDesk { + t.Error("turnModel infers a turn from the news desk, whose entries are deleted at every request end and every tool batch end") + } + } + if !found { + t.Fatal("turnModel is no longer in lanes.go, so this law is guarding nothing") + } +} diff --git a/internal/tui3/wiremodel_test.go b/internal/tui3/wiremodel_test.go new file mode 100644 index 0000000000..fb878c83b6 --- /dev/null +++ b/internal/tui3/wiremodel_test.go @@ -0,0 +1,163 @@ +package tui3 + +// THE LAW: THE CHROME NAMES THE MODEL THE ENGINE IS ON. +// +// The reported line is the whole of the argument. A person sent a message at +// 22:48:15, opened the picker at 22:48:18 and chose kimi-k3 at 22:48:21. The +// turn had latched glm-5.3-flash and ran there for seventeen minutes and +// forty-one calls, which is what [session.Agent.SetModel] promises. The seam said +// `moonshotai/kimi-k3 · auto · via wafer` throughout — the model cell from the +// picker's answer, the `via` cell from the endpoint of the request in flight. +// Two cells, two worlds, one line. + +import ( + "strings" + "testing" + "time" + + "github.com/Agent-Field/aforge-v2/internal/effort" + "github.com/Agent-Field/aforge-v2/internal/lane" + "github.com/Agent-Field/aforge-v2/internal/provider" +) + +// wireModelApp is that session at 22:48:22: the dial on the model just picked, +// a turn in flight on the model it started on, and a machine answering for it. +// +// THE TURN'S MODEL COMES OFF THE ENGINE AND THE MACHINE OFF THE NEWS, which is +// the division the surface itself keeps. A fixture that posted a phase and let +// the surface read the model out of it would be testing the inference this +// change deleted: a phase entry is gone at the end of every request and every +// tool batch, so that reading went empty between two steps of one turn. +func wireModelApp(t *testing.T) (*app, *fakeAgent, time.Time) { + t.Helper() + t.Cleanup(forgetPhases) + now := time.Date(2026, 9, 11, 22, 48, 22, 0, time.UTC) + engine := &fakeAgent{model: wireDial, turnModel: wireLatched} + a := newTestApp(engine) + a.width, a.height = 120, 24 + a.model, a.title = wireDial, "porting the parser" + a.file = "/tmp/lab/conv-one/session.jsonl" + a.state = stateWorking + a.clock = func() time.Time { return now } + PostPhaseNews(PhaseNews{ + Phase: provider.PhaseWriting, + Since: now.Add(-7 * time.Minute), + Model: wireLatched, + Lane: "wafer", + Role: lane.RoleTalk, + Session: a.taskSheetSelfID(), + At: now, + }) + return a, engine, now +} + +const ( + // wireDial is what the picker was last told, and wireLatched is what the turn + // in flight is really talking to. + wireDial = "moonshotai/kimi-k3" + wireLatched = "zhipu/glm-5.3-flash" +) + +// ONE READING FEEDS BOTH CELLS, so the model cell and the `via` cell cannot +// contradict each other: the name and the machine come off the same piece of +// news about the same request. +func TestTheSeamNamesTheModelTheTurnIsOnAndNotTheDial(t *testing.T) { + a, _, _ := wireModelApp(t) + + line := seamLine(t, a) + if !strings.Contains(line, "glm-5.3-flash") { + t.Fatalf("the seam does not name the model the turn is on: %q", line) + } + if strings.Contains(line, "kimi-k3") { + t.Fatalf("the seam still names the dial while another model is answering: %q", line) + } + if !strings.Contains(line, "via wafer") { + t.Fatalf("the seam lost the machine that is answering: %q", line) + } +} + +// AND AN IDLE CONVERSATION NAMES THE DIAL, which is the honest answer to what +// the next request will use. The fallback is a model, never nothing: the +// emptiness law is about a figure nobody has. +func TestAnIdleSeamNamesTheDial(t *testing.T) { + a, engine, _ := wireModelApp(t) + forgetPhases() + engine.turnModel = "" + a.state = stateIdle + + if line := seamLine(t, a); !strings.Contains(line, "kimi-k3") { + t.Fatalf("an idle seam does not name the dial: %q", line) + } +} + +// AND THE SWITCH SAYS WHAT IT DID NOT TOUCH, the way it already does for a +// running task. A person who picks a model mid-turn and then watches the answer +// go on arriving in the old voice has been told nothing unless it is said at the +// moment they acted. +func TestSwitchingModelMidTurnSaysWhatTheTurnKeeps(t *testing.T) { + a, _, _ := wireModelApp(t) + + a.switchModel("openai/gpt-5.4", 0) + + want := "model · openai/gpt-5.4 — this turn finishes on " + wireLatched + if got := plain(frame(a)); !strings.Contains(got, want) { + t.Fatalf("the switch said nothing about the turn in flight, want %q:\n%s", want, got) + } +} + +// AND IT SAYS NOTHING WHEN THERE IS NOTHING TO SAY. A switch on an idle +// conversation, and a switch onto the model the turn is already on, both leave +// the note as the id alone — a sentence about work that is not running is +// furniture a person has to read past. +func TestSwitchingModelSaysNothingExtraWhenNothingIsRunning(t *testing.T) { + for _, c := range []struct { + what string + idle bool + pick string + }{ + {what: "an idle conversation", idle: true, pick: "openai/gpt-5.4"}, + {what: "a pick of the model already running", pick: wireLatched}, + } { + a, engine, _ := wireModelApp(t) + if c.idle { + forgetPhases() + engine.turnModel = "" + a.state = stateIdle + } + a.switchModel(c.pick, 0) + if got := plain(frame(a)); strings.Contains(got, "this turn finishes on") { + t.Fatalf("%s was told about a turn it has no business hearing about:\n%s", c.what, got) + } + } +} + +// AND THE RUNG BESIDE THE NAME IS THAT MODEL'S RUNG. The levels a person sets +// with the picker's ctrl+t live per model id, so a rung resolved for the dial +// while the cell names the model in flight puts one model's name next to another +// model's level — two facts about two models, read as one sentence. +func TestTheSeamsRungIsTheRungOfTheModelItNames(t *testing.T) { + t.Cleanup(forgetPhases) + engine := &effortAgent{ + fakeAgent: &fakeAgent{model: wireDial, turnModel: wireLatched}, + perModel: map[string]effort.Rung{wireDial: effort.Max, wireLatched: effort.Low}, + } + a := newTestApp(engine) + a.width, a.height = 120, 24 + a.model, a.title = wireDial, "porting the parser" + a.state = stateWorking + + line := seamLine(t, a) + if !strings.Contains(line, effortLadderWord+" low") { + t.Fatalf("the rung is not the rung of the model the cell names: %q", line) + } + if strings.Contains(line, effortLadderWord+" max") { + t.Fatalf("the seam drew the dial's rung beside the model in flight: %q", line) + } + + // And an idle conversation's cell is the dial's, on both halves. + engine.turnModel = "" + a.state = stateIdle + if line := seamLine(t, a); !strings.Contains(line, effortLadderWord+" max") { + t.Fatalf("an idle seam does not draw the dial's own rung: %q", line) + } +} From c400085d6353f519782f6ab68cd000c250fa9934 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sat, 12 Sep 2026 10:25:02 -0400 Subject: [PATCH 2/8] chrome: the pick's sentence is the one the rooms already say MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #935 moved the law under this change: a person's word now wins AT THE REQUEST — a request nothing had come back from is cut and asked again on the model they named, and otherwise the answer arriving finishes and the next request carries it. Never the next turn. So `— this turn finishes on ` is a sentence that stopped being true, and it is deleted. What is true of BOTH landings is that the next request takes it, which is the word a task room already uses for the same fact ([roomModelNextWord]), so the chat says it in those words rather than growing a second vocabulary for one rule. AND THE SCRUB FOLLOWS THE SAME MOVE. A pick that cuts the request re-asks it inside the same turn, so a scrub that waited for the next turn would have handed a blind model a transcript still full of base64. [TestAPickThatCutsTheRequestTakesThePicturesWithIt] asserts that ON THE WIRE — what the second request actually carried — because the transcript was right before this change too and the request was not. The pictures test that read the transcript the instant the picker closed now drives a turn after the pick, because the move is what scrubs. Co-Authored-By: Claude Opus 5 (1M context) --- .../993-chrome-names-the-wire-model.md | 2 +- internal/manual/chat/models-and-cost.md | 5 +- internal/manual/chat/screen.md | 22 +-- internal/remote/wire.go | 2 +- internal/session/blindswap_test.go | 29 +++- internal/session/turnmodel_law_test.go | 107 ++++++++----- internal/session/turnmodel_test.go | 143 ++++++++++++------ internal/tui3/lanes.go | 13 +- internal/tui3/palette.go | 28 ++-- internal/tui3/wiremodel_test.go | 4 +- 10 files changed, 239 insertions(+), 116 deletions(-) diff --git a/docs/changes/unreleased/993-chrome-names-the-wire-model.md b/docs/changes/unreleased/993-chrome-names-the-wire-model.md index f58a3c5200..6754bc4378 100644 --- a/docs/changes/unreleased/993-chrome-names-the-wire-model.md +++ b/docs/changes/unreleased/993-chrome-names-the-wire-model.md @@ -12,7 +12,7 @@ invalidates: - "The blind-image scrub ran at the start of every turn. No longer true: it runs only when the model CHANGED since the last turn (`Agent.scrubbedFor`), so an oracle that answers differently twice for one id cannot reach back into a transcript nobody swapped. `TestAFlappingOracleCannotScrubAConversationThatNeverSwapped` and `TestASwapOntoAnUnknownModelKeepsThePictures` are the laws." - "A vision turn sealed itself with `Agent.Model()`, the dial, while the looking model had run it. No longer true: the seal takes the seer, which is the model named in the reply's own `[vision: ]` prefix. `TestTheWorkInFlightIsSealedUnderItsOwnModel` is the law." - "`session.Agent`'s phase news carried `a.model`, the dial. No longer true: every stage a turn posts — running, checking, tidying, taking stock — is stamped with the turn's latched model (`Agent.newsModel`), and a mid-turn `/model` changes none of them. The latch moves only when the turn itself hops to a fallback model, beside the `switching model` word that announces it." - - "`/model` mid-turn said nothing about the turn in flight, only about running tasks. No longer true: the note reads `model · — this turn finishes on `, derived from what the engine reports it is on, never from a surface flag." + - "`/model` mid-turn said nothing about the turn in flight, only about running tasks. No longer true: the note reads `model · — is answering now, and the next request takes it`, derived from what the engine reports it is on, never from a surface flag, and in the same words a task room uses for the same fact." - "`SetModel` scrubbed a blind model's pictures out of the live transcript immediately, under a running turn that was still sending them to a model that can see. No longer true: the scrub happens where the swap lands, at `Agent.startTurnLocked`, so the turn in flight keeps its images and the next turn starts clean." - "`auto` beside the model meant the thinking rung, with nothing on the cell saying so — and `auto` is also the lane dial's word (`/model auto`, the `lane` row in `/settings`), which is why it was read as \"lane: auto\" twice. Now spelled `⠿ thinking auto`: the cell names its own ladder at every rung (`⠿ thinking high`, `~ thinking low` on a plain terminal), not only at the word that collided." - "`runTurn` latched the turn's model by reading the dial again on its own side of the goroutine hand-off. No longer true: the latch is stamped under the same lock that binds the turn's client (`Agent.startTurnLocked`) and read back with `Agent.TurnModel()`, so a pick that lands between the two can no longer leave a turn talking to one model through a client built for another." diff --git a/internal/manual/chat/models-and-cost.md b/internal/manual/chat/models-and-cost.md index d78b28b975..d607bb3958 100644 --- a/internal/manual/chat/models-and-cost.md +++ b/internal/manual/chat/models-and-cost.md @@ -74,8 +74,9 @@ Choosing a model does four things: the model is set on the session, the machine the session learns that model's context window for compaction, a note appears reading `model · `, and the choice is written into your profile. **If a turn is still running when you pick, the note says what the switch did not touch** — -`model · moonshotai/kimi-k3 — this turn finishes on zhipu/glm-5.3-flash` — because a turn -finishes on the model it started on. +`model · moonshotai/kimi-k3 — zhipu/glm-5.3-flash is answering now, and the next request takes it` — the same +words a task's room uses, because it is the same fact. See the next section for which of +the two ways your pick lands. Over `--host`, the picker and its prices are this laptop's catalog, while the context window used for compaction comes from the far machine's catalog. The machine doing the diff --git a/internal/manual/chat/screen.md b/internal/manual/chat/screen.md index 122d40dfa2..6138b1885b 100644 --- a/internal/manual/chat/screen.md +++ b/internal/manual/chat/screen.md @@ -824,24 +824,28 @@ the door whether it can name the model a turn is on; against an older engine the names the model you picked, exactly as it did before, and `/model` says nothing about a turn in flight rather than guessing. -**Changing the model does not move the answer you are waiting for.** A turn finishes on -the model it started on — every step and every retry of it — and your pick takes effect -at the next message you send. `/model` says so at the moment you press it: +**Your pick reaches the work at its next request, not at the next turn.** If the request +in flight has given you nothing yet it is let go of at once and asked again on the model +you chose; if an answer is already arriving, that answer finishes and everything asked +for after it is on the new model. Either way it is the next request that carries it, and +that is what `/model` says at the moment you press it: ``` -model · moonshotai/kimi-k3 — this turn finishes on zhipu/glm-5.3-flash +model · moonshotai/kimi-k3 — zhipu/glm-5.3-flash is answering now, and the next request takes it ``` -If you want the new model *now*, stop the turn with `esc` and send the message again; -`ctrl+q` queues it to run after this one finishes, on the new model. +So the cell can change under you a second later, and that is the pick landing rather than +the line being wrong. "Can I switch models while it is replying" on the models page has +the whole rule. **Tasks already running keep their own model too**, and `/model` says that in the same line when any are running. A task's model is frozen when the task is admitted; tasks started after the switch follow it. -**Pictures you attached stay with the turn that is using them.** Switching to a model -that cannot see images replaces them with a line naming the file — but only from the -next turn, never under a turn that is still sending them to a model that can see. +**Pictures you attached stay with the model that can see them.** Switching to a model +that cannot see images replaces them with a line naming the file, and that happens when +the work actually moves onto the blind model — never while requests are still going to +one that can see. **And only a switch does that.** Turns of a conversation that never changed model leave the pictures alone, and so does a switch to a model aforge has read no row for: replacing diff --git a/internal/remote/wire.go b/internal/remote/wire.go index 3eb981650b..7b0c58d295 100644 --- a/internal/remote/wire.go +++ b/internal/remote/wire.go @@ -342,7 +342,7 @@ import ( // SENTENCE THAT IS FALSE. // AND 17 IS THE MODEL THE TURN IN FLIGHT IS ON ([Welcome.TurnModel], // [session.Facts.TurnModel]). The number moves because the surface draws a -// SENTENCE off it — `this turn finishes on ` — and a version-16 engine +// SENTENCE off it — ` is answering now` — and a version-16 engine // sends no such fact while its conversations still run turns: a surface that // filled the gap from the dial would say that sentence with the model the person // had just picked in both halves, which is a claim about the machine at the far diff --git a/internal/session/blindswap_test.go b/internal/session/blindswap_test.go index 1d1dcba114..10bbefe76d 100644 --- a/internal/session/blindswap_test.go +++ b/internal/session/blindswap_test.go @@ -41,11 +41,21 @@ func countImageParts(agent *Agent) int { return count } -// Switching onto a model without eyes turns the pictures into their -// placeholders — once, naming the file, with the person's own words left -// alone. +// Moving onto a model without eyes turns the pictures into their placeholders — +// once, naming the file, with the person's own words left alone. +// +// THE MOVE IS WHAT DOES IT, NOT THE PICK. A pick is a preference and may never +// be acted on; what may not happen is a request going to a model that cannot see +// with the base64 still in it. So the scrub rides the one door the model the +// work is talking to changes through (steer.go's [Agent.ridesOnLocked]), and +// this drives a turn after the pick rather than reading the transcript the +// instant the picker closes. func TestSetModelScrubsImagePartsForAModelThatCannotSee(t *testing.T) { - completer := &scriptedCompleter{} + completer := &scriptedCompleter{steps: []step{ + func(context.Context, []ai.Message) (*ai.Response, error) { return textResponse("seen"), nil }, + func(context.Context, []ai.Message) (*ai.Response, error) { return textResponse("blind"), nil }, + func(context.Context, []ai.Message) (*ai.Response, error) { return textResponse("still blind"), nil }, + }} agent, workspace := newTestAgent(t, completer, func(config *Config) { config.SeesImages = func(model string) ModelSight { return SightOf(model == "test/model") } config.SessionFile = writeableJournal(t) @@ -60,8 +70,12 @@ func TestSetModelScrubsImagePartsForAModelThatCannotSee(t *testing.T) { } agent.SetModel("vendor/blind") + if count := countImageParts(agent); count != 1 { + t.Fatalf("the pick alone took %d pictures away before any request went to the blind model", 1-count) + } + collect(t, mustSubmit(t, agent, "and now?")) if count := countImageParts(agent); count != 0 { - t.Fatalf("%d image parts survived the swap onto a blind model", count) + t.Fatalf("%d image parts survived the move onto a blind model", count) } text := transcriptText(agent) if !strings.Contains(text, "[image "+path+" — this model cannot see images]") { @@ -77,8 +91,9 @@ func TestSetModelScrubsImagePartsForAModelThatCannotSee(t *testing.T) { // Idempotent: a second swap between blind models rewrites nothing, because // there is nothing left to rewrite. agent.SetModel("vendor/blind-two") - if again := transcriptText(agent); again != text { - t.Fatalf("a second swap rewrote the transcript:\n%s", again) + collect(t, mustSubmit(t, agent, "and again?")) + if again := transcriptText(agent); !strings.Contains(again, text) { + t.Fatalf("a second move rewrote what the first one left:\n%s", again) } } diff --git a/internal/session/turnmodel_law_test.go b/internal/session/turnmodel_law_test.go index 63fa85df0d..40f856916a 100644 --- a/internal/session/turnmodel_law_test.go +++ b/internal/session/turnmodel_law_test.go @@ -1,31 +1,39 @@ package session -// THE LAW: THE TURN'S MODEL IS LATCHED IN ONE PLACE PER KIND OF TURN, AND MOVED -// BY ONE FUNCTION. +// THE LAW: THE MODEL THE WORK IS TALKING TO IS WRITTEN THROUGH ONE DOOR, AND +// THAT DOOR IS ALSO WHERE THE PICTURES ARE MADE SAFE. // -// `a.turnModel` is what every sentence about work in flight is drawn from — the -// phase news a surface's model cell reads ([Agent.newsModel]), the loop's own -// per-step model (loop.go), the sentence `/model` says about what a swap did not -// touch. A second place that writes it is a second answer to "what is this turn -// on", and the defect this whole change closes was exactly two answers to that -// question drawn side by side on one line. +// `a.riding` is what every sentence about work in flight is drawn from — the +// phase news a surface's model cell reads ([Agent.newsModel]), the door that +// decides whether a person's pick is news at all (steer.go), and, published as +// [Agent.TurnModel], the whole of what the chrome names. A second place that +// writes it is a second answer to "what is this turn on", and the defect this +// change closes was exactly two answers to that question drawn side by side on +// one line. // -// So it may be written by three functions and no fourth: +// So it is assigned by [Agent.ridesOnLocked] and by nothing else. The three +// places a move happens reach it through that one function: // -// - [Agent.startTurnLocked], where an ordinary turn is born, under the lock -// that binds its client; -// - [Agent.startVisionTurnLocked], where the one other kind of turn is born -// and the model it talks to is the looking model; -// - [Agent.turnMovedTo], the one move — a rescue hopping a spent model to the -// next in the chain, announced in the same breath. +// - [Agent.startTurnLocked] and [Agent.latchTheModel], where a turn is born — +// the first under the lock that binds its client, the second as the loop +// takes the person's word with it; +// - [Agent.startVisionTurnLocked], where the other kind of turn is born and +// the model it talks to is the looking model; +// - [Agent.rideModel], the move itself — a rescue hopping a spent model to the +// next in the chain, or a person's word taken at the request boundary. // -// AND IT IS NEVER CLEARED, which is the other half of the law and is why no -// fourth writer is needed: [Agent.turnModelLocked] answers "" unless a turn is -// running, so the flag that says there is work in flight is the flag that says -// this latch means anything. A clear would be a second thing to keep in step -// with `running`, and the first time the two disagreed the chrome would name a -// model nothing was talking to — which is the defect, back again by another -// road. +// ONE DOOR BECAUSE THE SCRUB HANGS OFF IT. A transcript's pictures are made safe +// for the model the work is talking to, so a writer that set the field without +// going through [Agent.ridesOnLocked] would move the work onto a blind model +// with the base64 still in the messages — which is the picture leak, arriving by +// the one road nobody would think to look down. +// +// AND IT IS NEVER CLEARED, which is why no fourth writer is needed: +// [Agent.TurnModel] answers "" unless a turn is running, so the flag that says +// there is work in flight is the flag that says this latch means anything. A +// clear would be a second thing to keep in step with `running`, and the first +// time the two disagreed the chrome would name a model nothing was talking to — +// which is the defect, back again by another road. import ( "go/ast" @@ -33,11 +41,18 @@ import ( "testing" ) -// turnModelWriters are the only functions that may assign the latch. -var turnModelWriters = map[string]bool{ +// turnModelWriters is the one function that may assign the latch. +var turnModelWriters = map[string]bool{"ridesOnLocked": true} + +// turnModelDoors are the functions that must go on reaching it — the two births +// and the move. A door that stopped calling it is a kind of turn whose model +// nothing publishes, which draws the LAST turn's model on the chrome for the +// length of this one, and a move that stopped calling it is the picture leak. +var turnModelDoors = map[string]bool{ "startTurnLocked": true, "startVisionTurnLocked": true, - "turnMovedTo": true, + "latchTheModel": true, + "rideModel": true, } func TestOnlyTheTurnsOwnDoorsLatchItsModel(t *testing.T) { @@ -55,13 +70,14 @@ func TestOnlyTheTurnsOwnDoorsLatchItsModel(t *testing.T) { } for _, target := range assign.Lhs { selector, ok := target.(*ast.SelectorExpr) - if !ok || selector.Sel.Name != "turnModel" { + if !ok || selector.Sel.Name != "riding" { continue } written[fn.Name.Name] = true if !turnModelWriters[fn.Name.Name] { - t.Errorf("%s writes the turn's latched model at %s — "+ - "the latch is stamped where a turn is born and moved only by turnMovedTo", + t.Errorf("%s writes the model the work is talking to at %s — "+ + "a.riding is assigned by ridesOnLocked alone, because that is where "+ + "the transcript's pictures are made safe for the model being moved to", fn.Name.Name, fset.Position(selector.Pos())) } } @@ -69,12 +85,35 @@ func TestOnlyTheTurnsOwnDoorsLatchItsModel(t *testing.T) { }) } }) - // AND EVERY DOOR IS STILL A DOOR. A writer that lost its assignment is a kind - // of turn whose model nothing latches, which draws the LAST turn's model on - // the chrome for the length of this one. - for door := range turnModelWriters { - if !written[door] { - t.Errorf("%s no longer latches the turn's model, so the work it starts names whatever the turn before it was on", door) + for writer := range turnModelWriters { + if !written[writer] { + t.Errorf("%s no longer assigns a.riding, so this law is guarding nothing", writer) + } + } + // AND EVERY DOOR IS STILL A DOOR. + called := map[string]bool{} + forEachPackageFile(t, func(path string, file *ast.File, fset *token.FileSet) { + for _, decl := range file.Decls { + fn, ok := decl.(*ast.FuncDecl) + if !ok || fn.Body == nil || !turnModelDoors[fn.Name.Name] { + continue + } + ast.Inspect(fn.Body, func(node ast.Node) bool { + call, ok := node.(*ast.CallExpr) + if !ok { + return true + } + if selector, ok := call.Fun.(*ast.SelectorExpr); ok && turnModelWriters[selector.Sel.Name] { + called[fn.Name.Name] = true + } + return true + }) + } + }) + for door := range turnModelDoors { + if !called[door] { + t.Errorf("%s no longer goes through ridesOnLocked, so the work it moves keeps the model "+ + "the turn before it was on and carries pictures the new model may not be able to see", door) } } } diff --git a/internal/session/turnmodel_test.go b/internal/session/turnmodel_test.go index 9139190535..235b44e922 100644 --- a/internal/session/turnmodel_test.go +++ b/internal/session/turnmodel_test.go @@ -1,14 +1,14 @@ package session -// THE LAW: A TURN IN FLIGHT IS ON ONE MODEL, IT SAYS WHICH, AND A SWAP MADE -// UNDER IT CHANGES NOTHING ABOUT IT. +// THE LAW: THE WORK IS ON ONE MODEL AT A TIME, IT SAYS WHICH, AND WHAT IS DRAWN +// BESIDE IT IS THAT MODEL. // -// [Agent.SetModel] has promised since it was written that a turn finishes on the -// model it started on, and [TestSetModelAppliesFromTheNextTurn] has held it to -// that on the wire. What was missing is everything that SAYS so: the phase news -// a surface draws its model cell from carried the dial, so a pick made three -// seconds into a turn re-labelled the whole of it, and the transcript's pictures -// were taken away from a model that was in the middle of reading them. +// steer.go decides WHEN a person's pick reaches the work — at the next request, +// either by cutting one nothing had come back from or by letting an arriving +// answer finish. What was missing is everything that SAYS so: the phase news a +// surface draws its model cell from carried the dial, so a pick made three +// seconds into a turn re-labelled the whole of it while every request went to +// the old model. import ( "context" @@ -19,23 +19,29 @@ import ( "github.com/Agent-Field/agentfield/sdk/go/ai" ) -// A swap made mid-turn leaves the turn's own model, the news about it, and the -// pictures it is still sending exactly where they were — and lands whole at the -// next turn, scrub included. -func TestAMidTurnSwapLeavesTheRunningTurnAlone(t *testing.T) { - entered := make(chan struct{}) +// A pick made while an answer is ARRIVING leaves the work where it is: the model +// the chrome names, the stages it posts, and the pictures it is still sending +// all stay on the model that is writing the reply. This is the half of steer.go's +// law that says a reply somebody is reading is theirs. +func TestAPickWhileAnAnswerIsArrivingLeavesTheWorkWhereItIs(t *testing.T) { + spoke := make(chan struct{}, 1) release := make(chan struct{}) completer := &scriptedCompleter{steps: []step{ - func(context.Context, []ai.Message) (*ai.Response, error) { - close(entered) - <-release - return toolResponse("c1", "ls", `{"path":"."}`), nil - }, - func(context.Context, []ai.Message) (*ai.Response, error) { - return textResponse("done"), nil + func(ctx context.Context, _ []ai.Message) (*ai.Response, error) { + provider.Emit(ctx, provider.StreamDelta, "here is the first half") + select { + case spoke <- struct{}{}: + default: + } + select { + case <-release: + case <-ctx.Done(): + return nil, ctx.Err() + } + return toolResponseWithText("c1", "ls", `{"path":"."}`, "here is the first half and the rest"), nil }, func(context.Context, []ai.Message) (*ai.Response, error) { - return textResponse("on the new one"), nil + return textResponse("and the step after is on the new model"), nil }, }} agent, workspace := newTestAgent(t, completer, func(config *Config) { @@ -49,64 +55,113 @@ func TestAMidTurnSwapLeavesTheRunningTurnAlone(t *testing.T) { defer cancel() events := mustSubmitImage(t, agent, ctx, "what is wrong with this?", []Image{{Path: path}}) select { - case <-entered: + case <-spoke: case <-time.After(10 * time.Second): - t.Fatal("the first step never started") + t.Fatal("the first request never streamed a word") } if got := agent.TurnModel(); got != "test/model" { - t.Fatalf("the turn in flight says it is on %q, want test/model", got) + t.Fatalf("the work in flight says it is on %q, want test/model", got) } agent.SetModel("vendor/blind") - // The dial moved and the turn did not. These are two different questions and + // The dial moved and the work did not. These are two different questions and // this is the whole of the reported defect: the surface asked the first one // and drew the answer as though it were the second. if got := agent.Model(); got != "vendor/blind" { t.Fatalf("the dial reads %q, want the model just picked", got) } if got := agent.TurnModel(); got != "test/model" { - t.Fatalf("the turn in flight moved to %q — SetModel touched work already running", got) + t.Fatalf("the work moved to %q while an answer was arriving on test/model", got) } - // AND THE PICTURES ARE STILL THERE. The steps left in this turn are still - // going to a model that can see them; replacing them with placeholders here - // takes a person's screenshot away mid-read. + // AND THE PICTURES ARE STILL THERE. The request that is answering can see + // them; replacing them with placeholders here takes a person's screenshot + // away mid-read, and buys nothing. if got := countImageParts(agent); got != 1 { - t.Fatalf("the running turn is left with %d pictures, want the 1 it started with", got) + t.Fatalf("the arriving answer is left with %d pictures, want the 1 it started with", got) } close(release) collect(t, events) - // Every stage this turn posted after the swap named the model the turn was - // on. One of them is the tool run released above. + // Every stage posted while that answer was arriving named the model it was + // arriving from. One of them is the tool call it ended with. said := false for _, news := range phases.all() { - if news.Phase == "" { + if news.Phase == "" || news.Model == "vendor/blind" { continue } said = true if news.Model != "test/model" { - t.Fatalf("a %q stage of the turn was posted under %q, want the model the turn is on", + t.Fatalf("a %q stage was posted under %q, want the model the work was on", news.Phase, news.Model) } } if !said { t.Fatal("the turn posted no stage at all, so the law was never exercised") } + if got := agent.TurnModel(); got != "" { + t.Fatalf("an idle session claims work on %q", got) + } +} + +// AND A PICK THAT CUTS THE REQUEST TAKES THE PICTURES WITH IT. This is the other +// half of the law and the hole the first cut of this change left: when nothing +// has come back the request is let go of and asked again ON THE NEW MODEL inside +// the same turn, so a scrub that waited for the next TURN would have handed a +// blind model a transcript full of base64 — the leak arriving by the one road +// nobody would think to look down. +func TestAPickThatCutsTheRequestTakesThePicturesWithIt(t *testing.T) { + out := make(chan struct{}, 1) + sent := make(chan []ai.Message, 1) + completer := &scriptedCompleter{steps: []step{ + waitingRequest(out), + func(_ context.Context, messages []ai.Message) (*ai.Response, error) { + select { + case sent <- append([]ai.Message(nil), messages...): + default: + } + return textResponse("answered without the picture"), nil + }, + }} + agent, workspace := newTestAgent(t, completer, func(config *Config) { + config.SeesImages = func(model string) ModelSight { return SightOf(model == "test/model") } + config.SessionFile = writeableJournal(t) + }) + path := writeImage(t, workspace, "chart.png", "PHOTOBYTES") + + ctx, cancel := deadline(20 * time.Second) + defer cancel() + events := mustSubmitImage(t, agent, ctx, "what is wrong with this?", []Image{{Path: path}}) + select { + case <-out: + case <-time.After(10 * time.Second): + t.Fatal("the first request never went out") + } - // AND THE SWAP LANDS WHOLE AT THE NEXT TURN — the model on the wire and the - // scrub together, because the one place a next turn begins is where both - // happen ([Agent.startTurnLocked]). - collect(t, mustSubmit(t, agent, "again")) - if got := completer.model(2); got != "vendor/blind" { - t.Fatalf("the next turn rode %q, want vendor/blind", got) + agent.SetModel("vendor/blind") + collect(t, events) + + if got := completer.model(1); got != "vendor/blind" { + t.Fatalf("the re-ask rode %q, want the model the person named", got) } - if got := countImageParts(agent); got != 0 { - t.Fatalf("the turn on the blind model still carries %d pictures", got) + // THE ASSERTION IS ON THE WIRE. What the blind model was actually handed is + // the only thing that answers this, because the transcript was right before + // this change too and the request was not. + select { + case messages := <-sent: + pictures := 0 + for _, message := range messages { + pictures += len(imagePartURLs(message)) + } + if pictures != 0 { + t.Fatalf("the request to the blind model carried %d pictures", pictures) + } + default: + t.Fatal("the re-ask never reached the completer") } - if got := agent.TurnModel(); got != "" { - t.Fatalf("an idle session claims a turn on %q", got) + if got := countImageParts(agent); got != 0 { + t.Fatalf("%d pictures are still in the transcript the blind model is being sent", got) } } diff --git a/internal/tui3/lanes.go b/internal/tui3/lanes.go index d589ef0983..4d1fcde794 100644 --- a/internal/tui3/lanes.go +++ b/internal/tui3/lanes.go @@ -1212,15 +1212,16 @@ func (a *app) pinnedNow() string { // This is the law, and one line of one reported session is the whole argument // for it. A person sent a message, opened the picker three seconds later and // chose kimi-k3. The turn had already latched glm-5.3-flash and ran there for -// seventeen minutes and forty-one calls — which is what [session.Agent.SetModel] -// promises and what the engine did. The seam said +// seventeen minutes and forty-one calls — the answer was arriving, so by +// steer.go's law the pick rode the next request the work made. The seam said // `moonshotai/kimi-k3 · auto · via wafer` for all of it: the model cell drawn // from [app.model], the picker's answer, and the `via` cell drawn from the // endpoint the request in flight had named. Two cells, two worlds, one line, and // neither cell wrong by its own reckoning. // // So both cells now come off ONE fact: [session.Agent.TurnModel], the model the -// engine latched when this turn opened. It is the same fact the engine stamps on +// engine is talking to — written when the turn opens and moved only when the +// work itself moves. It is the same fact the engine stamps on // every stage of the work it is doing ([session.Agent.newsModel]), so the `via` // machine and the name above it are two readings of one turn — and it crosses a // --host connection on the photograph the rung beside it already rides @@ -1248,9 +1249,9 @@ func (a *app) wireModel() string { // [phaseClock.done], internal/session's [Agent.endPhase]), so between two steps // of one turn the desk is empty and the seam would fall back to the dial — the // cell flipping back and forth across every step boundary — and a pick that -// landed in one of those gaps would find nothing to say `finishes on` about. The -// latch has none of those holes: it is set when the turn opens and stands until -// the turn ends. +// landed in one of those gaps would find nothing to say about the work in +// flight. The engine's own fact has none of those holes: it is written when the +// turn opens and stands until the turn ends. // // IT IS GATED ON THE SURFACE'S OWN STATE, never on a desk entry. `stateWorking` // is this window's answer to "is a turn in flight", kept by the stream it is diff --git a/internal/tui3/palette.go b/internal/tui3/palette.go index 7841d1c257..e5aa2166b1 100644 --- a/internal/tui3/palette.go +++ b/internal/tui3/palette.go @@ -1959,18 +1959,26 @@ func (a *app) switchModel(id string, window int) { // started after this line follow the switch (taskmodel.go's // defaultTaskModel reads the live dial), which is why the sentence is // about the running ones only. - // AND THE TURN IN FLIGHT IS THE FIRST THING IT SAYS, because it is the thing - // the person is watching. [session.Agent.SetModel] latches a turn's model - // when the turn opens and never moves it, so a swap made three seconds into - // a turn changes nothing about the seventeen minutes that follow — and until - // this line the surface said nothing at all about that, while drawing the - // model just picked on the seam beside the old model's endpoint. The sentence - // is derived from what the engine is on rather than from a flag: if the - // engine is naming a model, there is work on it, and if that model is the one - // just picked there is nothing to say. + // AND THE WORK IN FLIGHT IS THE FIRST THING IT SAYS, because it is the thing + // the person is watching. Until this line the surface said nothing at all + // about it while drawing the model just picked on the seam beside the OLD + // model's endpoint — one line naming two models, three seconds after a pick + // that had not reached the work. The sentence is derived from what the engine + // is on ([app.turnModel]) rather than from a flag: if the engine is naming a + // model there is work on it, and if that model is the one just picked there + // is nothing to say. + // + // AND IT SAYS WHAT IS TRUE OF BOTH LANDINGS, in the room's own words + // ([roomModelNextWord]). Under steer.go's law a pick either cuts a request + // nothing had come back from and asks it again, or lets the answer arriving + // finish — and either way it is THE NEXT REQUEST that carries it, which is + // why one sentence can be true without the surface knowing which happened. + // It deliberately does not say "turn": an earlier cut of this line promised + // the turn would FINISH on the old model, which stopped being true the day + // a person's word started winning at the request. note := "model · " + a.model if turnOn != "" && !strings.EqualFold(turnOn, a.model) { - note += " — this turn finishes on " + turnOn + note += " — " + turnOn + " is answering now, and " + roomModelNextWord } if a.deckRunning() > 0 { note += " — tasks already running keep the model they started on" diff --git a/internal/tui3/wiremodel_test.go b/internal/tui3/wiremodel_test.go index fb878c83b6..44829dc042 100644 --- a/internal/tui3/wiremodel_test.go +++ b/internal/tui3/wiremodel_test.go @@ -99,7 +99,7 @@ func TestSwitchingModelMidTurnSaysWhatTheTurnKeeps(t *testing.T) { a.switchModel("openai/gpt-5.4", 0) - want := "model · openai/gpt-5.4 — this turn finishes on " + wireLatched + want := "model · openai/gpt-5.4 — " + wireLatched + " is answering now, and " + roomModelNextWord if got := plain(frame(a)); !strings.Contains(got, want) { t.Fatalf("the switch said nothing about the turn in flight, want %q:\n%s", want, got) } @@ -125,7 +125,7 @@ func TestSwitchingModelSaysNothingExtraWhenNothingIsRunning(t *testing.T) { a.state = stateIdle } a.switchModel(c.pick, 0) - if got := plain(frame(a)); strings.Contains(got, "this turn finishes on") { + if got := plain(frame(a)); strings.Contains(got, "is answering now") { t.Fatalf("%s was told about a turn it has no business hearing about:\n%s", c.what, got) } } From 1e9a5e29210ad61d4358e4a54fbad2bf8a6ceba6 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sat, 12 Sep 2026 10:32:35 -0400 Subject: [PATCH 3/8] tests: the note is read as a sentence, not as the width of the window The switch note is long enough that the transcript wraps it onto a continuation lead, so a Contains against the frame verbatim was asserting on where the column broke it. The package already had [unwrapped] for exactly this; this uses it rather than growing a second one. Co-Authored-By: Claude Opus 5 (1M context) --- internal/tui3/wiremodel_test.go | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/internal/tui3/wiremodel_test.go b/internal/tui3/wiremodel_test.go index 44829dc042..eb8e679dbb 100644 --- a/internal/tui3/wiremodel_test.go +++ b/internal/tui3/wiremodel_test.go @@ -100,8 +100,12 @@ func TestSwitchingModelMidTurnSaysWhatTheTurnKeeps(t *testing.T) { a.switchModel("openai/gpt-5.4", 0) want := "model · openai/gpt-5.4 — " + wireLatched + " is answering now, and " + roomModelNextWord - if got := plain(frame(a)); !strings.Contains(got, want) { - t.Fatalf("the switch said nothing about the turn in flight, want %q:\n%s", want, got) + // THE NOTE IS READ WITH ITS WRAPPING FOLDED BACK ([unwrapped]). This surface + // wraps a note onto a continuation lead rather than cutting it, so asserting + // on the frame verbatim would be asserting on the width of the test's window + // rather than on what it says. + if got := unwrapped(strings.Split(plain(frame(a)), "\n")); !strings.Contains(got, want) { + t.Fatalf("the switch said nothing about the turn in flight, want %q:\n%s", want, plain(frame(a))) } } From 75212a32a65f30c83b81ffea6954383896356e78 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sat, 12 Sep 2026 10:42:23 -0400 Subject: [PATCH 4/8] tests: the memory-only scrub is asserted on the move too, and the note past the tray's rule Two tests were reading the transcript the instant the picker closed; the move is what scrubs now, so both drive the turn after the pick. And the switch note is wrapped with the tray's rule between its halves, so the frame is read with that furniture taken out before the sentence is looked for. Co-Authored-By: Claude Opus 5 (1M context) --- internal/session/blindswap_test.go | 7 ++++++- internal/tui3/wiremodel_test.go | 12 +++++++----- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/internal/session/blindswap_test.go b/internal/session/blindswap_test.go index 10bbefe76d..de2b677027 100644 --- a/internal/session/blindswap_test.go +++ b/internal/session/blindswap_test.go @@ -188,7 +188,10 @@ func TestResumingOnABlindModelScrubsTheReplayedPictures(t *testing.T) { // session has no file to have written a path, and "no path" is not a reason to // send base64 to a blind model. func TestTheScrubDropsBytesEvenWithNoJournaledPath(t *testing.T) { - agent, _ := newTestAgent(t, &scriptedCompleter{}, func(config *Config) { + completer := &scriptedCompleter{steps: []step{ + func(context.Context, []ai.Message) (*ai.Response, error) { return textResponse("blind"), nil }, + }} + agent, _ := newTestAgent(t, completer, func(config *Config) { config.SeesImages = func(model string) ModelSight { return SightOf(model == "test/model") } }) agent.mu.Lock() @@ -198,7 +201,9 @@ func TestTheScrubDropsBytesEvenWithNoJournaledPath(t *testing.T) { }}) agent.mu.Unlock() + // The move is what scrubs, so the turn after the pick is what this asserts on. agent.SetModel("vendor/blind") + collect(t, mustSubmit(t, agent, "and now?")) if count := countImageParts(agent); count != 0 { t.Fatalf("%d image parts survived", count) } diff --git a/internal/tui3/wiremodel_test.go b/internal/tui3/wiremodel_test.go index eb8e679dbb..3592557598 100644 --- a/internal/tui3/wiremodel_test.go +++ b/internal/tui3/wiremodel_test.go @@ -100,11 +100,13 @@ func TestSwitchingModelMidTurnSaysWhatTheTurnKeeps(t *testing.T) { a.switchModel("openai/gpt-5.4", 0) want := "model · openai/gpt-5.4 — " + wireLatched + " is answering now, and " + roomModelNextWord - // THE NOTE IS READ WITH ITS WRAPPING FOLDED BACK ([unwrapped]). This surface - // wraps a note onto a continuation lead rather than cutting it, so asserting - // on the frame verbatim would be asserting on the width of the test's window - // rather than on what it says. - if got := unwrapped(strings.Split(plain(frame(a)), "\n")); !strings.Contains(got, want) { + // THE NOTE IS READ WITH ITS WRAPPING FOLDED BACK ([unwrapped]), AND WITH THE + // COLUMN THE LAYOUT DREW BESIDE IT TAKEN OUT. This surface wraps a note onto + // a continuation lead rather than cutting it, and the tray's rule sits + // between the two halves of a wrapped row — so a Contains against the frame + // verbatim is a test about the width of the window and where its furniture + // landed, rather than about what was said. + if got := unwrapped(strings.Split(strings.ReplaceAll(plain(frame(a)), "│", " "), "\n")); !strings.Contains(got, want) { t.Fatalf("the switch said nothing about the turn in flight, want %q:\n%s", want, plain(frame(a))) } } From f6f87599162624b8e446bc53fb70d181d7a07b62 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sat, 12 Sep 2026 11:13:36 -0400 Subject: [PATCH 5/8] changelog: the entry says what #935 changed under it The scrub line named SetModel and startTurnLocked; it is ridesOnLocked now, and the reason is the request boundary rather than the turn one. The duplicate scrub line is deleted, the phase-news line names ridingNowLocked, and the opening no longer says SetModel promises a turn finishes where it started, which stopped being true. Co-Authored-By: Claude Opus 5 (1M context) --- .../unreleased/993-chrome-names-the-wire-model.md | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/docs/changes/unreleased/993-chrome-names-the-wire-model.md b/docs/changes/unreleased/993-chrome-names-the-wire-model.md index 6754bc4378..9174471f53 100644 --- a/docs/changes/unreleased/993-chrome-names-the-wire-model.md +++ b/docs/changes/unreleased/993-chrome-names-the-wire-model.md @@ -4,24 +4,23 @@ title: the model on the seam is the model the engine is on, and the rung beside pr: 993 surface: [chat, engine] invalidates: - - "The foot's model cell showed the model you last picked. No longer true: while a turn is running it names the model that turn is actually on, and only an idle conversation shows the dial. The door is `internal/tui3.app.wireModel`, which reads `session.Agent.TurnModel()` — the engine's own latch, asked directly and NEVER inferred from a news-desk entry, because a phase entry is deleted at the end of every request and every tool batch and a reading taken from its presence went empty between two steps of one turn. `TestTheSeamNamesTheModelTheTurnIsOnAndNotTheDial` is the law." + - "The foot's model cell showed the model you last picked. No longer true: while a turn is running it names the model that turn is actually on, and only an idle conversation shows the dial. The door is `internal/tui3.app.wireModel`, which reads `session.Agent.TurnModel()` — `a.riding`, the engine's own record of what the step in flight is talking to, asked directly and NEVER inferred from a news-desk entry, because a phase entry is deleted at the end of every request and every tool batch and a reading taken from its presence went empty between two steps of one turn. `TestTheSeamNamesTheModelTheTurnIsOnAndNotTheDial` is the law." - "Only the seam's model cell read the model in flight. No longer true: every cell drawn beside it does — the `via` rider and its sighting lookups, the `waiting for ` word, the `@lane` pin, the `/status` model and lane rows (one reading now, not two), and the press targets behind the chip (`/model`, `/lane`, the picker's fold). `internal/tui3/wiremodel_law_test.go` is now a package-wide ratchet: every function that reads `a.model` is named in `dialReaders` with the reason it means the dial, and a new reader that is not named fails the build." - "Over `--host` the surface had no way to know the model a far turn was on. No longer true: `session.Facts.TurnModel` crosses on the photograph the thinking rung already rides, `remote.Welcome.TurnModel` states whether the far engine keeps the fact, and the wire version is 17. Against a version-16 engine the seam draws the dial alone and `/model` says nothing about a turn in flight — a capability that cannot work is absent, not guessed." - "The thinking rung on the seam was resolved for the dial. No longer true: it resolves for the model the cell names (`session.Agent.ResolvedEffortFor`, mirrored on `remote.Agent` and carried in `Facts.Thinking`). Reasoning levels live per model id, so a rung resolved for the dial drew one model's name beside another model's level, and a fallback hop moved the turn's rung without moving the drawn one." - "`session.Config.SupportsImages func(string) bool` is gone, replaced by `SeesImages func(string) ModelSight` with three answers — `SightUnknown`, `SightBlind`, `SightSees`. Sending pictures needs a positive `sees`; taking them out of a live transcript needs a positive `blind`. As one bool the two shared the reading 'unknown counts as blind', which let a failed catalog fetch strip a conversation's pictures for good." - - "The blind-image scrub ran at the start of every turn. No longer true: it runs only when the model CHANGED since the last turn (`Agent.scrubbedFor`), so an oracle that answers differently twice for one id cannot reach back into a transcript nobody swapped. `TestAFlappingOracleCannotScrubAConversationThatNeverSwapped` and `TestASwapOntoAnUnknownModelKeepsThePictures` are the laws." + - "The blind-image scrub ran in `SetModel`, on the pick. No longer true: it rides `Agent.ridesOnLocked`, the one door the model the work is talking to changes through — a turn opening, a rescue's hop, or a person's word taken at the request boundary — and only when the model actually changed (`Agent.scrubbedFor`). A pick is a preference that may never be acted on, and since #935 a pick can reach the REQUEST in flight, which a scrub keyed to the picker missed by one request. `TestAPickThatCutsTheRequestTakesThePicturesWithIt` asserts it on the wire; `TestAFlappingOracleCannotScrubAConversationThatNeverSwapped` and `TestASwapOntoAnUnknownModelKeepsThePictures` guard the other two edges." - "A vision turn sealed itself with `Agent.Model()`, the dial, while the looking model had run it. No longer true: the seal takes the seer, which is the model named in the reply's own `[vision: ]` prefix. `TestTheWorkInFlightIsSealedUnderItsOwnModel` is the law." - - "`session.Agent`'s phase news carried `a.model`, the dial. No longer true: every stage a turn posts — running, checking, tidying, taking stock — is stamped with the turn's latched model (`Agent.newsModel`), and a mid-turn `/model` changes none of them. The latch moves only when the turn itself hops to a fallback model, beside the `switching model` word that announces it." + - "`session.Agent`'s phase news carried `a.model`, the dial. No longer true: every stage a turn posts — running, checking, tidying, taking stock — is stamped with the model the work is on (`Agent.newsModel`, which is `ridingNowLocked`), so a pick that has not reached the work yet changes none of them." - "`/model` mid-turn said nothing about the turn in flight, only about running tasks. No longer true: the note reads `model · — is answering now, and the next request takes it`, derived from what the engine reports it is on, never from a surface flag, and in the same words a task room uses for the same fact." - - "`SetModel` scrubbed a blind model's pictures out of the live transcript immediately, under a running turn that was still sending them to a model that can see. No longer true: the scrub happens where the swap lands, at `Agent.startTurnLocked`, so the turn in flight keeps its images and the next turn starts clean." - "`auto` beside the model meant the thinking rung, with nothing on the cell saying so — and `auto` is also the lane dial's word (`/model auto`, the `lane` row in `/settings`), which is why it was read as \"lane: auto\" twice. Now spelled `⠿ thinking auto`: the cell names its own ladder at every rung (`⠿ thinking high`, `~ thinking low` on a plain terminal), not only at the word that collided." - - "`runTurn` latched the turn's model by reading the dial again on its own side of the goroutine hand-off. No longer true: the latch is stamped under the same lock that binds the turn's client (`Agent.startTurnLocked`) and read back with `Agent.TurnModel()`, so a pick that lands between the two can no longer leave a turn talking to one model through a client built for another." + - "`a.riding` was written in three places. No longer true: `Agent.ridesOnLocked` is the only writer, because publishing the model the work is talking to and making the transcript's pictures safe for it are one event — a writer that set the field without it would move the work onto a blind model with the base64 still in the messages. `TestOnlyTheTurnsOwnDoorsLatchItsModel` pins the writer and the four doors that reach it." --- One rule closes all of it: THE CHROME NAMES THE MODEL THE ENGINE IS ON. A person sent a message at 22:48:15, opened the picker at 22:48:18 and chose kimi-k3 at 22:48:21; the turn had latched glm-5.3-flash and ran there for -seventeen minutes and forty-one calls, which is exactly what `SetModel` -promises. The seam read `moonshotai/kimi-k3 · auto · via wafer` throughout — +seventeen minutes and forty-one calls — the answer was arriving, so by +`internal/session/steer.go`'s law the pick rode the next request the work made. The seam read `moonshotai/kimi-k3 · auto · via wafer` throughout — the model cell from the picker's answer, the `via` cell from the endpoint of the request in flight — and neither cell was wrong on its own terms. There is one reading behind both cells now, and no second road: the model the turn is on From ce71a1e1b858a36a40254f58e33f56d4278e2c6e Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sat, 12 Sep 2026 11:30:39 -0400 Subject: [PATCH 6/8] chrome: a frame reads the wire model once, and the wire carries no flag for it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three from the review. A FRAME READS A MEMO (PERF.md). Ten cells named the model the engine is on and each one took a lock on the turn loop's own mutex — or, over --host, copied a whole session.Facts out of the photograph — thirty times a second, so the surface was competing with the work it was drawing. app.frameBody takes ONE reading at the top of every frame into app.wire and every cell reads that; app.readWire is the only thing that asks. waitingWords took it twice on two lines and now names it once. The three presses that ACT on it — switchModel's note, the picker, /lane — take their own reading, because a press is rare, is a person acting, and writes something that outlives the frame. AND THE CAPABILITY FLAG IS DELETED, with the version bump it carried. An unequal wire Version is refused at the door (client.go), so a connected engine is always this build and Welcome.TurnModel was constant true. A flag exists to tell "cannot answer" from "the answer is nothing", and here they cannot be confused: "" already means nothing is in flight, and a surface told nothing draws the dial. Version goes back to 16. remote's resolvedEffortForDoor was declared and never referenced. AND THE WHEEL STARTS FROM THE RUNG ON THE SCREEN. The chip drew ResolvedEffortFor(wireModel) while ctrl+t walked from ResolvedEffort() — the dial — so after a mid-turn pick it stepped off a word the person was not looking at. effortDialer has one question now, asked of one model, by all four readers. Co-Authored-By: Claude Opus 5 (1M context) --- internal/remote/effort.go | 7 ---- internal/remote/server.go | 1 - internal/remote/turnmodel.go | 48 ++++++++------------------- internal/remote/wire.go | 29 +--------------- internal/tui3/app.go | 15 +++++++++ internal/tui3/effortchip.go | 33 +++++++++++-------- internal/tui3/lanes.go | 51 ++++++++++++++++++++--------- internal/tui3/palette.go | 13 ++++++-- internal/tui3/render.go | 7 ++-- internal/tui3/view.go | 5 +++ internal/tui3/wiremodel_law_test.go | 10 +++--- 11 files changed, 110 insertions(+), 109 deletions(-) diff --git a/internal/remote/effort.go b/internal/remote/effort.go index c5232f4ffb..e0dad25117 100644 --- a/internal/remote/effort.go +++ b/internal/remote/effort.go @@ -36,13 +36,6 @@ type effortDoor interface { SetConversationEffort(rung string) bool } -// resolvedEffortForDoor is the per-model half of the dial, asserted separately -// so an engine that predates it still advertises a dial ([effortKnown]) and -// still answers for the conversation as a whole. -type resolvedEffortForDoor interface { - ResolvedEffortFor(model string) string -} - // effortKnown is whether this engine has the whole dial. A partial one is no // dial at all: the surface draws the resolved rung and moves the stored one, so // an engine with one of the three would light a cell nothing could turn. diff --git a/internal/remote/server.go b/internal/remote/server.go index ed8c203a45..e31fc33648 100644 --- a/internal/remote/server.go +++ b/internal/remote/server.go @@ -1147,7 +1147,6 @@ func (sess *Session) welcomeLocked(s *server) Welcome { // And whether it can name the model the turn in flight is on, asked the // same way and for the same reason: "" is a real answer here, so only the // door can tell an idle conversation from an engine with no latch. - TurnModel: turnModelKnown(sess.agent), TaskSettle: taskSettleKnown(sess.agent), // Whether this conversation's news reaches the surface at all, asked the // way the newsroom files it ([Session.fileNews]): an engine that cannot diff --git a/internal/remote/turnmodel.go b/internal/remote/turnmodel.go index e348142c76..e27f146fd3 100644 --- a/internal/remote/turnmodel.go +++ b/internal/remote/turnmodel.go @@ -2,47 +2,27 @@ package remote // ── THE MODEL THE TURN IN FLIGHT IS ON, ACROSS THE WIRE ───────────────────── // -// internal/session latches a turn's model when the turn opens and moves it only -// when the turn itself hops to a fallback ([session.Agent.TurnModel]). The -// surface names that model wherever it names this conversation's model, so the -// cell and the `via` machine beside it cannot disagree (internal/tui3's -// [app.wireModel]) — and over a connection the fact has to cross. +// internal/session publishes the model the work is talking to +// ([session.Agent.TurnModel]). The surface names that model wherever it names +// this conversation's model, so the cell and the `via` machine beside it cannot +// disagree (internal/tui3's [app.wireModel]) — and over a connection the fact +// has to cross. // // IT RIDES THE PHOTOGRAPH AND IS NEVER A CALL. The seam draws it on every // repaint, and PERF.md's law is that a View over --host issues zero far calls, // so it arrives on the fact set the engine states unasked ([session.Facts], // replica.go) exactly as the thinking rung beside it does. // -// AND THE CAPABILITY IS THE WELCOME'S TO ANSWER, not the type system's. Every -// connection has the method on it, and "" is a real answer — an idle -// conversation — so neither the assertion nor the answer can tell an engine that -// has no latch from a conversation that is simply not working. -// [Welcome.TurnModel] is the only thing that can. +// AND IT NEEDS NO CAPABILITY FLAG, which is worth saying because the rung beside +// it has one. A flag exists to tell "this engine cannot answer" from "the answer +// is nothing", and those are only different when they look the same — an empty +// rung could mean either. Here they cannot be confused: "" already means NOTHING +// IS IN FLIGHT, which is exactly what a surface should draw against an engine +// that sends no such fact, and the seam falls back to the dial by itself. The +// version handshake settles the rest: [Version] is refused unequal at the door +// (client.go), so a connected engine is this build and always has the fact. -// turnModelDoor is the latch as an engine must have it to serve one. -// -// It is asserted rather than required for [effortDoor]'s reason — a capability -// is asserted, never required — so an engine built before the latch keeps its -// conversations and simply advertises nothing. -type turnModelDoor interface { - TurnModel() string -} - -// turnModelKnown is whether this engine can name the model a turn is on. -func turnModelKnown(agent any) bool { _, ok := agent.(turnModelDoor); return ok } - -// TurnModelSupported answers for THE MACHINE AT THE OTHER END, off what it said -// at the door. -func (a *Agent) TurnModelSupported() bool { return a.c.Welcome().TurnModel } - -// TurnModel is the model the turn in flight is on, and "" when nothing is in -// flight OR when the far engine does not keep the fact. -// -// THE TWO EMPTIES ARE DELIBERATELY THE SAME STRING HERE, and the caller tells -// them apart with [Agent.TurnModelSupported] before it draws anything — which is -// the same division [Agent.ResolvedEffort] and [Agent.EffortSupported] keep, for -// the same reason: a reading taken on every frame must not carry a second -// return nobody checks. +// TurnModel is the model the turn in flight is on, and "" when nothing is. // // IT IS A MEMORY READ, for [Agent.ResolvedEffort]'s stated reason. func (a *Agent) TurnModel() string { return a.c.facts.read().TurnModel } diff --git a/internal/remote/wire.go b/internal/remote/wire.go index 7b0c58d295..9af4290009 100644 --- a/internal/remote/wire.go +++ b/internal/remote/wire.go @@ -340,16 +340,7 @@ import ( // half a minute while this surface had stopped waiting at ten seconds: the // person would be told their work did not start while it did. NEVER TO A // SENTENCE THAT IS FALSE. -// AND 17 IS THE MODEL THE TURN IN FLIGHT IS ON ([Welcome.TurnModel], -// [session.Facts.TurnModel]). The number moves because the surface draws a -// SENTENCE off it — ` is answering now` — and a version-16 engine -// sends no such fact while its conversations still run turns: a surface that -// filled the gap from the dial would say that sentence with the model the person -// had just picked in both halves, which is a claim about the machine at the far -// end that nothing at the far end made. NEVER TO A SENTENCE THAT IS FALSE: at -// 16 the flag is absent, the surface draws the dial alone, and it says nothing -// about what a turn in flight is finishing on. -const Version = 17 +const Version = 16 // AND THE NEWS FRAMES RIDE THAT SAME NUMBER, for the reason the places methods // rode version 5's: neither half can be surprised by them. "phase" and "lane" @@ -1070,24 +1061,6 @@ type Welcome struct { // has never heard of. Effort bool `json:"effort,omitempty"` - // TurnModel says this engine CAN NAME THE MODEL THE TURN IN FLIGHT IS ON — - // that its agent answers [session.Agent.TurnModel] and states it on the fact - // set it pushes ([session.Facts.TurnModel]). - // - // IT IS CARRIED FOR [Welcome.Effort]'S REASON, AND THE STAKES ARE HIGHER. A - // surface at this end holds a *remote.Agent, which ALWAYS has the method, so - // the assertion says yes for every connection and nothing about the far - // machine. And the ANSWER cannot separate them either: "" is a real answer - // here — an idle conversation with nothing in flight — so an engine that has - // never heard of the latch and a conversation that is simply not working are - // the same empty string. - // - // ABSENCE IS false AND false MEANS THE DIAL ALONE. The seam names what the - // person picked, which is what it named before this existed, and the switch - // says nothing about a turn in flight — because the only honest thing to say - // about a fact the far machine does not keep is nothing. - TurnModel bool `json:"turnModel,omitempty"` - // Folders says this engine CAN HOLD THE FOLDERS A CONVERSATION IS ABOUT — // that its agent answers [MethodPlacesRefer] and [MethodPlacesRemove] rather // than refusing them (wire_places.go). diff --git a/internal/tui3/app.go b/internal/tui3/app.go index 86b1662fb3..838ff9122e 100644 --- a/internal/tui3/app.go +++ b/internal/tui3/app.go @@ -980,6 +980,17 @@ type app struct { state runState model string + // wire is THE MODEL THE ENGINE IS ON as of the last reading, "" when nothing + // is in flight. It is a MEMO and never the source: [app.readWire] takes the + // reading — a lock on the engine, or a whole photograph over `--host` — and + // [app.frameBody] takes exactly one per frame, because the cells drawn beside + // the model are ten readings of one fact and thirty frames a second of them + // is a surface competing with the work it is drawing (PERF.md). + // + // It sits beside the dial deliberately: `model` is what the person picked and + // this is what is answering, and the two being one field apart is the whole + // of what this window has to keep straight. + wire string // title is the name the session gave itself, shown left of the model. Empty // until the session has one (session's title.go starts naming it with the // first accepted message); a resumed session opens with the name it already had. @@ -6728,10 +6739,14 @@ func (a *app) slash(line string) tea.Cmd { // chrome is naming ([app.wireModel]): `/lane` is read beside the chip // that spells `model@lane`, and pinning the dial while another model is // answering would put the instruction on a model nobody is looking at. + // The reading is TAKEN here rather than read off the frame's memo, + // because a typed instruction writes something that outlives the frame. case modelPinLane: + a.readWire() a.pinLane(a.wireModel(), value) return nil case modelAutoLane: + a.readWire() a.clearLanePin(a.wireModel()) return nil case modelQuery: diff --git a/internal/tui3/effortchip.go b/internal/tui3/effortchip.go index b6e540e280..60b95bc989 100644 --- a/internal/tui3/effortchip.go +++ b/internal/tui3/effortchip.go @@ -174,13 +174,17 @@ type effortFlashMsg struct{} // simply has no chip — the design law that a capability which cannot work is // absent rather than broken. type effortDialer interface { - // ResolvedEffort is the rung the next turn will actually ask for, whichever - // scope decided it. - ResolvedEffort() string - // ResolvedEffortFor is that rung FOR ONE MODEL ID, which is the question the - // seam's cell actually asks: the rung is drawn beside a model and must be - // that model's, and the levels the picker sets live per model id. An empty id - // is the dial, which is what an idle conversation's cell names. + // ResolvedEffortFor is the rung one MODEL ID will actually ask for, whichever + // scope decided it, and it is the only question this surface asks: the rung + // is drawn beside a model and must be that model's, because the levels the + // picker sets live per model id. An empty id is the dial, which is what an + // idle conversation's cell names. + // + // THERE IS NO MODEL-LESS VERSION, deliberately. `ResolvedEffort()` — the rung + // for the dial — was what the chip's WHEEL started from while the chip DREW + // the rung of the model the engine is on, so after a mid-turn pick ctrl+t + // walked away from a word the person was not looking at. One question, asked + // of one model, by all four readers. ResolvedEffortFor(model string) string // ConversationEffort is the rung THIS conversation was set to, "" when // nobody has set one. @@ -231,7 +235,7 @@ func (a *app) effortWord() string { if !ok { return "" } - return dial.ResolvedEffort() + return dial.ResolvedEffortFor(a.wireModel()) } // effortChipText is the chip unpainted — the mark and the word beside it, which @@ -243,8 +247,7 @@ func (a *app) effortWord() string { // states were one string until 2026-09-09 and the cell was therefore missing on // every conversation of a shipped install, which is the whole defect. func (a *app) effortChipText() string { - dial, ok := a.effortDial() - if !ok { + if _, ok := a.effortDial(); !ok { return "" } // THE RUNG IS RESOLVED FOR THE MODEL THIS CELL SITS BESIDE, which is the @@ -253,7 +256,7 @@ func (a *app) effortChipText() string { // then picked another mid-turn had one model's name drawn next to the other // model's level — and a fallback hop moved the turn's own rung (loop.go // re-resolves for the model it hopped to) without moving the drawn one. - word := dial.ResolvedEffortFor(a.wireModel()) + word := a.effortWord() if word == "" { word = effortAutoWord } @@ -346,7 +349,11 @@ func (a *app) cycleEffort() tea.Cmd { // effortscope.go). A second copy here would be a second place for "and it // wraps" to stop being true, and a person who learns the walk on the chip // knows it on a task for exactly as long as the two agree. - return a.setEffortRung(dial, effortNext(effort.Rung(dial.ResolvedEffort()))) + // AND IT STARTS FROM THE RUNG ON THE SCREEN. The chip draws the level of the + // model the engine is on, so a wheel that started from the dial's level would + // step off a word nobody is looking at the moment those two differ — which is + // every mid-turn pick. + return a.setEffortRung(dial, effortNext(effort.Rung(a.effortWord()))) } // setEffortRung is the ONE path from the chord and from the menu to the session, @@ -365,7 +372,7 @@ func (a *app) setEffortRung(dial effortDialer, rung effort.Rung) tea.Cmd { } a.effortLit = effortMoved{where: effortScopeConversation, at: a.now()} a.touch() - if got := dial.ResolvedEffort(); got != rung.String() { + if got := dial.ResolvedEffortFor(a.wireModel()); got != rung.String() { // The note names the model whose own level is winning and the door that // moves it, because "this did not take" without either is a message that // leaves a person pressing the key harder. THE PAYLOAD RULE lifts the id diff --git a/internal/tui3/lanes.go b/internal/tui3/lanes.go index 4d1fcde794..c577b100bd 100644 --- a/internal/tui3/lanes.go +++ b/internal/tui3/lanes.go @@ -1232,12 +1232,31 @@ func (a *app) pinnedNow() string { // use, which is what a person just picked; the emptiness law is about figures // nobody has, and this is a figure everybody has. func (a *app) wireModel() string { - if model := a.turnModel(); model != "" { - return model + if a.wire != "" { + return a.wire } return a.model } +// readWire takes the reading and REMEMBERS IT, and it is the only thing that +// asks the engine. +// +// A FRAME READS A MEMO (PERF.md). The cells drawn beside the model — the seam's +// word, the waiting line, both `via` sightings, the rider, the deck's chip, the +// rung — are ten readings of one fact in one frame, and each one is a lock taken +// on the turn loop's own mutex, or over `--host` a whole [session.Facts] copied +// out of the photograph. Thirty frames a second of that is a surface competing +// with the work it is drawing. So [app.frameBody] takes ONE reading at the top +// of every frame and every cell reads the memo. +// +// AND A PRESS TAKES ITS OWN, because a press is rare, is a person acting, and +// wants the engine's answer rather than the answer the last frame happened to +// draw ([app.switchModel] and the two picker doors). +func (a *app) readWire() string { + a.wire = a.askTurnModel() + return a.wire +} + // turnModel is [session.Agent.TurnModel] as it reaches this surface: the model // the turn in flight is on, and "" when nothing is in flight or when nothing at // the far end keeps the fact. @@ -1257,18 +1276,16 @@ func (a *app) wireModel() string { // is this window's answer to "is a turn in flight", kept by the stream it is // reading, and it is the same answer the composer's own hints are drawn from. // -// AND A FAR ENGINE THAT DOES NOT KEEP THE FACT ANSWERS NOTHING, by the door it -// stated at the welcome ([remote.Agent.TurnModelSupported]) — not by the empty -// string, which is a real answer here. A capability that cannot work is absent -// rather than broken: the seam names the dial, exactly as it did before this -// existed, and the switch says nothing about a turn in flight. -// // It is [app.wireModel] WITHOUT the fallback, and the two are kept apart for the // one caller that has to tell the difference: a switch mid-turn says what it did // not touch ([app.switchModel]), and a fallback to the dial would answer that // question with the model the person just picked — which is the sentence being // nothing at all. -func (a *app) turnModel() string { +// +// THIS IS THE READING ITSELF and it is taken through [app.readWire], never +// straight from a cell: it locks the engine, and a frame draws this fact ten +// times. +func (a *app) askTurnModel() string { if a.state != stateWorking { return "" } @@ -1287,18 +1304,20 @@ func (a *app) turnModel() string { // method added to that interface is a method thirty test doubles have to grow // before a model can be drawn. // -// AND A CONNECTION ANSWERS FOR THE MACHINE AT THE OTHER END, exactly as it does -// for the rung ([app.effortDial] states the whole argument): a *remote.Agent -// always has the method, so the assertion alone says yes for every `--host` -// session whatever the far engine can do. +// AND THERE IS NO CAPABILITY FLAG BESIDE IT, which is worth saying because the +// rung next to it has one ([app.effortDial]). A flag exists to tell "this engine +// cannot answer" from "the answer is nothing", and those are only worth telling +// apart when they look the same — an empty rung could be either. Here they +// cannot be confused: "" already means NOTHING IS IN FLIGHT, and a surface that +// is told nothing draws the dial, which is the right cell for a conversation +// with no turn on it. The version handshake settles the rest — an unequal +// [remote.Version] is refused at the door — so a connection that exists is a +// connection to this build. func (a *app) turnModelDoor() (interface{ TurnModel() string }, bool) { if a.agent == nil { return nil, false } door, ok := a.agent.(interface{ TurnModel() string }) - if host, hosted := a.agent.(interface{ TurnModelSupported() bool }); hosted { - ok = ok && host.TurnModelSupported() - } return door, ok } diff --git a/internal/tui3/palette.go b/internal/tui3/palette.go index e5aa2166b1..2f3c5e5ff2 100644 --- a/internal/tui3/palette.go +++ b/internal/tui3/palette.go @@ -1745,6 +1745,7 @@ func (a *app) openPicker() { // what the press acts on. A picker that opened with the cursor on the dial // after a mid-turn pick would be a list about a model the person is not // looking at. + a.readWire() wire := a.wireModel() a.pick.startFor(a.modelList(), wire, chatModel) // THE PIN IS A SNAPSHOT, exactly as the model in use is: it is what marks a @@ -1920,13 +1921,19 @@ func (a *app) windowFor(id string) int { // fires at a fraction of it, so a session that switched to a 1M model without // saying so would keep compacting as if it were on the 128k one it started on. func (a *app) switchModel(id string, window int) { - // WHAT THE TURN IN FLIGHT IS ON, READ BEFORE THE DIAL MOVES. It is the - // engine's own answer and not a flag this surface keeps — [app.turnModel] is + // WHAT THE WORK IS ON, READ BEFORE THE DIAL MOVES. It is the engine's own + // answer and not a flag this surface keeps — [app.readWire] is // [session.Agent.TurnModel] as it crossed the seam — so a model named here IS // the engine saying there is work in flight and what it is talking to. It is // read first because the dial is about to become the answer to a different // question. - turnOn := a.turnModel() + // + // AND THE READING IS TAKEN RATHER THAN REMEMBERED. Everything drawn reads a + // memo refreshed once a frame, which is right for thirty frames a second of + // cells; a press is one act by a person and the sentence it writes into the + // conversation is permanent, so it is worth the one lock to have it be true + // at the moment they pressed rather than true a sixtieth of a second ago. + turnOn := a.readWire() a.agent.SetModel(id) a.model = a.agent.Model() if a.model == "" { diff --git a/internal/tui3/render.go b/internal/tui3/render.go index d21400e08f..0ad02385c3 100644 --- a/internal/tui3/render.go +++ b/internal/tui3/render.go @@ -1535,6 +1535,9 @@ func (a *app) waitingWords() string { return "" } word := waitBareWord + // Read once and named, because the case below wants it twice and it is a + // memo of the engine rather than a field ([app.wireModel]). + waiting := modelBase(a.wireModel()) switch { case a.retrying: // AND IT SAYS WHICH TRY, out of the struct the feed's own row was @@ -1561,8 +1564,8 @@ func (a *app) waitingWords() string { // on and not the one the picker last set ([app.wireModel]). `waiting for // kimi-k3` under a seam reading `glm-5.3-flash` would be two models named on // one screen about one wait. - case modelBase(a.wireModel()) != "": - word = waitForWord + modelBase(a.wireModel()) + case waiting != "": + word = waitForWord + waiting } if clock := countUpWord(waited); clock != "" { word += " · " + clock diff --git a/internal/tui3/view.go b/internal/tui3/view.go index 200a37c8cb..c53677c965 100644 --- a/internal/tui3/view.go +++ b/internal/tui3/view.go @@ -274,6 +274,11 @@ func (a *app) frame() (string, int, int) { // one composition pass [app.frame] puts over the whole of it. func (a *app) frameBody() (string, int, int) { a.inlineWaitShowing = false + // THE MODEL THE ENGINE IS ON IS READ ONCE, HERE, and every cell that names it + // reads the memo ([app.readWire], lanes.go). A frame draws that fact about ten + // times — the seam's word, the waiting line, both `via` sightings, the rider, + // the deck chip, the rung — and each reading locks the turn loop's own mutex. + a.readWire() width, height := a.size() if a.pasteEdit.open { return a.pasteEditorFrame(width, height) diff --git a/internal/tui3/wiremodel_law_test.go b/internal/tui3/wiremodel_law_test.go index 9882825583..d50752a2f5 100644 --- a/internal/tui3/wiremodel_law_test.go +++ b/internal/tui3/wiremodel_law_test.go @@ -154,7 +154,7 @@ func TestEveryDialReaderIsOneThatMeansTheDial(t *testing.T) { } } -// AND THE WIRE READING ITSELF KEEPS ITS TWO HALVES. [app.turnModel] must ask the +// AND THE WIRE READING ITSELF KEEPS ITS TWO HALVES. [app.askTurnModel] must ask the // engine and must never infer a turn from the news desk: a phase entry is // deleted at the end of every request and every tool batch, so a reading taken // from its presence goes empty between two steps of one turn — the cell flipping @@ -173,7 +173,7 @@ func TestTheWireReadingAsksTheEngineAndNotTheNewsDesk(t *testing.T) { found := false for _, decl := range file.Decls { fn, ok := decl.(*ast.FuncDecl) - if !ok || fn.Body == nil || fn.Name.Name != "turnModel" { + if !ok || fn.Body == nil || fn.Name.Name != "askTurnModel" { continue } found = true @@ -199,13 +199,13 @@ func TestTheWireReadingAsksTheEngineAndNotTheNewsDesk(t *testing.T) { return true }) if !asksEngine { - t.Error("turnModel no longer asks the engine for its latched model") + t.Error("askTurnModel no longer asks the engine for the model it is on") } if readsDesk { - t.Error("turnModel infers a turn from the news desk, whose entries are deleted at every request end and every tool batch end") + t.Error("askTurnModel infers a turn from the news desk, whose entries are deleted at every request end and every tool batch end") } } if !found { - t.Fatal("turnModel is no longer in lanes.go, so this law is guarding nothing") + t.Fatal("askTurnModel is no longer in lanes.go, so this law is guarding nothing") } } From 72eb1bcd6d91c9fe58d9c82b0f1ebb016f426d5f Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sat, 12 Sep 2026 11:36:01 -0400 Subject: [PATCH 7/8] pictures: the guard is on the wire, so a hop can no longer cost a screenshot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit THE REWRITE OF THE TRANSCRIPT WAS THE DEFECT, not the implementation of it. Three ways to lose a picture for good, all of them arriving with nobody having asked for anything: - a RESCUE HOP. A model stops answering, the ladder moves the step onto a fallback that happens to be blind, and a rewrite at the move destroys the pictures of a conversation whose own model sees fine. - an ORACLE THAT CHANGES ITS MIND about one id. - an ORDERING. A picture queued while a turn runs lands in the transcript after the rewrite for that model has already happened. `scrubbedFor` patched the second and made the third worse. So the guard moves to the wire: `blindSafe` is a pure function of (messages, sees, known) called where the one request is assembled from the transcript (snapshotWithReasoning), for the model the work is talking to. Asked again for every request, same answer for the same model, and hopping back to a model that sees shows the pictures again. The transcript and the journal both keep the bytes; `Agent.scrubBlindImagePartsLocked`, `Agent.scrubbedFor`, `Agent.ridesOnLocked` and `sessionFile.rememberImagePath` are all deleted, and so is the rewrite the resume door did at construction. AND THE ORACLE USES THE CODEBASE'S OWN IDIOM. `ModelSight`/`SightOf` are replaced by `SeesImages func(model) (sees, known bool)`, which is how `Config.ReasoningProfile` and `Config.SupportsParameter` either side of it already spell "and whether anybody knows". AND IT NO LONGER GRADES ITS OWN IGNORANCE AS A FACT. A row with an EMPTY input_modalities list published nothing, and reading that as a known `cannot see` handed the silence law's answer — which governs what is SENT — to the guard that hides pictures, the opposite direction. Now: `image` in the list sees, a non-empty list without it is known blind, an empty or absent one is unknown. AND BOTH GATES READ THE MODEL THE WORK IS ON. SubmitImage and documentModel graded an attachment against the dial while the request guard and the phase news read the riding model, so a picture attached mid-turn was judged against a model that was not going to see it — in both directions. Co-Authored-By: Claude Opus 5 (1M context) --- cmd/aforge/chatv3.go | 50 ++++++---- internal/session/agent.go | 42 +++----- internal/session/blindswap.go | 170 ++++++++++++++++---------------- internal/session/image.go | 23 +++-- internal/session/reasoning.go | 15 +++ internal/session/session.go | 59 ++++++----- internal/session/sessionfile.go | 24 ----- internal/session/steer.go | 42 +++----- internal/session/tools_doc.go | 4 +- 9 files changed, 204 insertions(+), 225 deletions(-) diff --git a/cmd/aforge/chatv3.go b/cmd/aforge/chatv3.go index 26a3127a31..ad2c49dd35 100644 --- a/cmd/aforge/chatv3.go +++ b/cmd/aforge/chatv3.go @@ -2117,44 +2117,51 @@ func v3AnswersText(outputs []string) bool { // The file is read at most once per session: it is the same rows for the whole // warming window, and re-reading it per message would put I/O on the message // path to learn nothing new. -// AND IT ANSWERS IN THREE STATES, NOT TWO ([session.ModelSight]). A row this -// door has actually read is a `sees` or a `blind`; a catalog that has not -// answered yet and a disk cache that does not carry the id are `unknown`, and -// the difference decides whether a person's screenshot survives. Until the third -// state existed a cold catalog said `blind` in the same breath a warm one said -// `sees`, for one id, one conversation, a minute apart — and the scrub that read -// it took the pictures out of the transcript for good (internal/session's -// blindswap.go states what each caller does with which answer). -func v3SeesImages(models v3Catalog) func(string) session.ModelSight { +// AND IT SAYS WHETHER IT KNOWS, which is the shape the facts beside it use +// ([session.Config.SeesImages]). The difference decides what happens to a +// person's screenshot: until it existed a cold catalog said `cannot see` in the +// same breath a warm one said `sees`, for one id, one conversation, a minute +// apart. +// +// THREE ANSWERS OUT OF ONE ROW, and the third is not the same as the second. A +// list that names `image` SEES. A non-empty list that does not name it is a row +// that published its modalities and left image out, which is a model this door +// KNOWS is blind. An EMPTY list is a row that published nothing at all — and +// that is ignorance, not a no. The silence law (docs/MULTIMODAL.md Decision 6) +// governs what is SENT, and the send gate honours it by refusing anything but a +// positive `sees`; grading that silence as a known `cannot see` also handed it +// to the guard that hides pictures, which is the opposite direction and the one +// that used to be irreversible. +func v3SeesImages(models v3Catalog) func(string) (bool, bool) { var once sync.Once var cached []tui3.Model - return func(model string) session.ModelSight { + return func(model string) (sees, known bool) { model = strings.TrimSpace(model) if model == "" { - return session.SightUnknown + return false, false } if models != nil { if rows := models.ModelsNow(); len(rows) > 0 { for _, row := range rows { if strings.EqualFold(strings.TrimSpace(row.ID), model) { - return session.SightOf(v3ReadsImages(row.InputModalities)) + return v3ReadsImages(row.InputModalities) } } // The catalog HAS answered and does not carry this id — a // hand-typed slug, a model this router never listed. Nothing has // been READ about this model, so nothing is known about it: it is - // refused a picture like any unknown, and its pictures are left - // where they are like any unknown. - return session.SightUnknown + // refused a picture like any unknown, and its pictures are shown + // like any unknown's. + return false, false } } once.Do(func() { cached = tui3.CachedModels() }) for _, row := range cached { if strings.EqualFold(strings.TrimSpace(row.ID), model) { - return session.SightOf(v3ReadsImages(row.Input)) + return v3ReadsImages(row.Input) } } - return session.SightUnknown + return false, false } } @@ -2171,13 +2178,16 @@ func v3SeesImages(models v3Catalog) func(string) session.ModelSight { // with a base64 photo in it, sent to a model that cannot read one, comes back as // a provider error about a content part, while an unknown modality is a refusal // the person can act on ("switch to a model with vision"). -func v3ReadsImages(inputs []string) bool { +func v3ReadsImages(inputs []string) (sees, known bool) { for _, modality := range inputs { if strings.EqualFold(strings.TrimSpace(modality), "image") { - return true + return true, true } } - return false + // A ROW THAT PUBLISHED NOTHING HAS SAID NOTHING. An empty list is the shape a + // router uses for "no architecture block on this model", and reading it as a + // published `text only` was this door grading its own ignorance as a fact. + return false, len(inputs) > 0 } // v3NearestModels is the last resort of the endpoint-refusal chain: when the diff --git a/internal/session/agent.go b/internal/session/agent.go index 1b965d952e..7b2117e8b3 100644 --- a/internal/session/agent.go +++ b/internal/session/agent.go @@ -288,19 +288,15 @@ func newAgent(config Config, client Completer) (*Agent, error) { // is the one place the two are converted, by doing the shaping. agent.earlier = shapeEntries(replayed.earlier, file) agent.earlierFloor = len(shapeEntries(restored[:replayed.overlap], file)) - // AND THE PICTURES ARE MADE SAFE HERE RATHER THAN IN THE REPLAY. A - // session resumed onto a model without vision — the journal remembers - // the model it was written on, the person can start it on another — would - // otherwise re-send yesterday's base64 to a model that cannot read it, - // which is [Agent.SetModel]'s hole through the other door. The scrub is - // the same function both doors call, and it is done HERE because - // [replaySessionFile] is a pure function of the file: which model this - // session will ride, and whether it can see, are facts about the agent, - // and threading a capability closure into a file parser would put the - // question in the one layer that cannot answer it. No lock is taken for - // the reason nothing else in this constructor takes one — the agent is - // not reachable yet. - agent.scrubBlindImagePartsLocked(agent.model) + // A SESSION RESUMED ONTO A MODEL WITHOUT VISION NEEDS NOTHING DONE HERE, + // and that is worth a line because it used to. Yesterday's base64 comes + // back out of the journal whatever model this session opens on, and the + // rewrite that used to happen here — once, at the door, against the model + // the agent happened to start with — is now a question asked of every + // request as it is assembled (blindswap.go). A resume onto a blind model + // sends placeholders; a resume onto a blind model that the person then + // moves off shows the pictures again, which the one-way rewrite could + // never do. // AND IT KEEPS WHAT IT SPENT. The total is the sum of the file's usage // lines (sessionfile.go), so a conversation reopened tomorrow reports the // money it actually cost rather than the money this process has spent so @@ -672,16 +668,6 @@ func (a *Agent) TurnModel() string { // (loop.go's completeWithRetry). It is a rescue and not a preference: what a // person set here is untouched, so the NEXT turn starts on the model they // picked, and the only way this field changes is somebody calling this. -// -// THE PICTURES ARE NOT MADE SAFE HERE, and that is the change this door owes -// its own paragraph. A conversation carrying attached images carries them as -// base64 in the live transcript, re-sent on every request after they arrived, so -// moving onto a model that cannot see would send them to it with no gate in the -// way — no image is being attached. The scrub therefore rides THE MODEL THE WORK -// IS TALKING TO rather than the dial ([Agent.ridesOnLocked], steer.go): the dial -// is a preference that may never be acted on, and under the law above a pick can -// reach the request in flight, which a scrub that ran only here would have -// missed by one request. func (a *Agent) SetModel(model string) { a.setModel(model) } // setModel is SetModel with the answer to "when does this land", which the doors @@ -1640,10 +1626,12 @@ func (a *Agent) startTurnLocked(ctx context.Context, user userMessage, watcher * // the binding and the reading and the turn talked to one model through a // client built for another. Everything that has to name the work in flight // reads this latch afterwards ([Agent.TurnModel]). - // AND THE PICTURES ARE MADE SAFE FOR IT IN THE SAME BREATH, through the one - // door a move goes through (steer.go's [Agent.ridesOnLocked]), which scrubs - // only when the model actually moved. - a.ridesOnLocked(a.model) + // AND THE MODEL THE WORK IS ON IS PUBLISHED HERE TOO, not only by the loop's + // own [Agent.latchTheModel] on the other side of the goroutine hand-off: a + // surface that repainted in between read the model of the turn BEFORE this + // one. The two writes are the same value, and the loop's is the one that also + // takes the person's word. + a.riding = a.model a.lastTurnTruncated = false // AND ANOTHER WINDOW HEARS ABOUT IT NOW rather than at the next heartbeat // (taskpresence.go). The nudge never blocks and never takes a lock, which is diff --git a/internal/session/blindswap.go b/internal/session/blindswap.go index b0739ef6b8..05e0f9d916 100644 --- a/internal/session/blindswap.go +++ b/internal/session/blindswap.go @@ -1,78 +1,102 @@ package session -// The transcript guard: what happens to the pictures in a conversation when the -// model swaps to one that cannot see them. +// The picture guard: what a model that cannot see is sent when the conversation +// in front of it carries pictures. // -// SubmitImage's gate reads the model the next turn will ride, which closes the -// hole in one direction only. A picture attached to a model that could see it -// sits in the LIVE transcript as a base64 data URL and is re-sent on every step -// of every turn afterwards — so a person who attaches a screenshot and then -// switches to a model without eyes (/model, one keystroke) sends that data URL -// to a blind model on a turn where nobody attached anything and no gate ran. -// That is the 400 the gate exists to prevent, arriving from behind. +// SubmitImage's gate reads the model the work is on, which closes the hole in +// one direction only. A picture attached to a model that could see it sits in +// the LIVE transcript as a base64 data URL and is re-sent on every request +// afterwards — so a person who attaches a screenshot and then moves onto a model +// without eyes (/model, one keystroke; or a rescue hopping off a rate limit, +// which nobody asked for at all) sends that data URL to a blind model on a +// request where nobody attached anything and no gate ran. That is the 400 the +// gate exists to prevent, arriving from behind. // -// So the SWAP is the seam, and the resume beside it. A model that can see is -// handed the conversation untouched. A model that cannot reads each picture's -// PLACEHOLDER — [journalPart.placeholderBecause], the replay sentence's shape -// with this seam's own honest reason ("this model cannot see images") — naming -// the path, because the path is the half a person can act on and the half the -// model can hand to view_image. +// SO THE SUBSTITUTION IS ON THE WIRE AND THE BYTES ARE NEVER TOUCHED. The +// request assembled for a blind model carries each picture's PLACEHOLDER — +// [journalPart.placeholderBecause], the replay sentence's shape with this seam's +// own honest reason ("this model cannot see images") — naming the path, because +// the path is the half a person can act on and the half the model can hand to +// view_image. The transcript keeps the picture. // -// THREE THINGS ARE DELIBERATE. The journal is never rewritten, by stub.go's -// law: the file is the RECORD, and the pictures in it are what a resume rebuilds -// from. The scrub is ONE-WAY — switching back to a seeing model does not restore -// the bytes — because the alternative is re-reading files off disk mid-turn, -// which is the second quiet place a changed file becomes a picture nobody -// attached. And a picture's journaled path is re-indexed onto its placeholder, -// so a surface drawing the conversation still names what was attached, exactly -// as it does for a replayed picture whose file went away. +// IT WAS A ONE-WAY REWRITE OF THE TRANSCRIPT UNTIL 2026-09-12, AND THAT WAS THE +// DEFECT, not the implementation of it. Three ways to lose a screenshot for +// good, all of them arriving with nobody having asked for anything: +// +// - a RESCUE HOP. A model stops answering, the ladder moves the step onto a +// fallback that happens to be blind, and a rewrite at the move destroys the +// pictures of a conversation whose own model can see them perfectly well. +// - an ORACLE THAT CHANGES ITS MIND. The same id answered `sees` off a warm +// cache and `blind` a minute later off a catalog fetch that had failed. +// - AN ORDERING. A picture queued while a turn runs lands in the transcript +// after the rewrite for that model has already happened. +// +// A remembered "I already did this one" ([Agent.scrubbedFor]) patched the second +// and made the third worse. A function of the request cannot have any of them: +// it is asked again for every request, it is the same answer for the same model, +// and hopping back to a model that sees shows it the pictures again. +// +// THE JOURNAL IS NEVER REWRITTEN EITHER, by stub.go's law: the file is the +// RECORD, and the pictures in it are what a resume rebuilds from. import "github.com/Agent-Field/agentfield/sdk/go/ai" -// scrubBlindImagePartsLocked replaces this transcript's image parts with their -// placeholders when the given model cannot read them, with a.mu held. It is a -// no-op — not one allocation — for a model that can see and for the -// conversations, which are nearly all of them, that carry no pictures. +// blindSafe is the messages as they may be SENT to this model: unchanged for a +// model that can see and for a model nobody has vouched for, and with every +// picture replaced by its placeholder for a model known to be blind. +// +// IT IS A PURE FUNCTION OF THE MODEL AND THE MESSAGES, which is the whole design +// (see the header). Nothing is remembered, nothing is written back, and asking +// twice gives the same answer. // -// IT SCRUBS ON A POSITIVE `BLIND` AND ON NOTHING ELSE ([ModelSight]). An -// unvouched model — a cold catalog, a failed fetch, a row that published no -// modalities — keeps its pictures. That asymmetry with the send gate, which -// refuses on anything but a positive `sees`, is the point: refusing to send -// costs a person one turn and a sentence, and scrubbing costs them the -// screenshot, for ever, with no way back. On 2026-09-11 the two shared one bool -// and the same model id answered `sees` off the disk cache and `blind` a minute -// later off a catalog fetch that had failed — which was enough to strip a -// conversation's pictures with nobody having swapped anything. -func (a *Agent) scrubBlindImagePartsLocked(model string) { - if a.sightOf(model) != SightBlind { - return +// IT SUBSTITUTES ON A POSITIVE `BLIND` AND ON NOTHING ELSE. An unvouched model — +// a cold catalog, a failed fetch, a row that published no modalities — is sent +// the conversation as it stands. That asymmetry with the send gate, which +// refuses on anything but a positive `sees`, is deliberate: refusing to send +// costs a person one turn and a sentence, and the alternative here is sending a +// picture to a model that can read it perfectly well but published nothing about +// itself. The gate is the cautious one because it is the reversible one. +// +// It allocates nothing for the conversations, which are nearly all of them, that +// carry no pictures at all. +func (a *Agent) blindSafe(messages []ai.Message, sees, known bool) []ai.Message { + if sees || !known { + return messages } - for index, message := range a.messages { + var out []ai.Message + for index, message := range messages { if !hasImagePart(message) { continue } + if out == nil { + out = make([]ai.Message, len(messages)) + copy(out, messages) + } content := make([]ai.ContentPart, len(message.Content)) for at, part := range message.Content { if part.Type != "image_url" || part.ImageURL == nil { content[at] = part continue } - path := a.file.imagePath(part) - placeholder := journalPart{Type: journalPartImage, Path: path}.placeholderBecause("this model cannot see images") - a.file.rememberImagePath(placeholder, path) - content[at] = placeholder + content[at] = journalPart{Type: journalPartImage, Path: a.file.imagePath(part)}. + placeholderBecause("this model cannot see images") } // A NEW message, never a write into the old content, for the reason - // stub.go states at its own replacement: a request already in flight - // holds a shallow copy of this message ([Agent.snapshot]), and editing - // the parts underneath it would edit a request the provider is reading. - a.messages[index] = ai.Message{ + // stub.go states at its own replacement: the transcript keeps these + // messages and a request already in flight holds a shallow copy of them, + // so editing the parts underneath would edit what somebody else is + // reading. + out[index] = ai.Message{ Role: message.Role, Content: content, ToolCalls: message.ToolCalls, ToolCallID: message.ToolCallID, } } + if out == nil { + return messages + } + return out } // hasImagePart is the cheap question asked of every message before anything is @@ -88,43 +112,23 @@ func hasImagePart(message ai.Message) bool { // ── WHAT THIS BUILD KNOWS ABOUT A MODEL'S EYES ────────────────────────────── -// ModelSight is the answer to "can this model read a picture", in THREE states, -// because two were not enough to hold both decisions that ask. +// seesImages is what this session knows about one model: whether it can read a +// picture, and whether anybody has said either way. // -// THE THIRD STATE IS THE ONE THAT MATTERS. A catalog that has not loaded, a -// fetch that failed, a model nobody published modalities for — all of them are -// "nobody has said", and for a whole release that was spelled the same way as -// "this model is blind". The send gate was right to refuse on it. The scrub was -// wrong to fire on it, and the scrub is the one that cannot be undone. -type ModelSight uint8 - -const ( - // SightUnknown is nobody having vouched either way. It REFUSES A SEND and - // FORBIDS A SCRUB: neither act is safe on a guess, and they fail in opposite - // directions, so the same ignorance answers them differently. - SightUnknown ModelSight = iota - // SightBlind is a model this build knows cannot read pictures. - SightBlind - // SightSees is a model this build knows can. - SightSees -) - -// SightOf is a yes-or-no oracle's answer as a sight, for a caller that really -// does know both ways — a fixture, a catalog row that carries modalities. A -// caller that might be ignorant must say [SightUnknown] itself rather than -// passing false, which is exactly the collapse this type exists to prevent. -func SightOf(sees bool) ModelSight { - if sees { - return SightSees - } - return SightBlind -} - -// sightOf is what this session knows about one model, with [SightUnknown] for a -// session wired with no oracle at all. -func (a *Agent) sightOf(model string) ModelSight { +// THE SECOND RETURN IS THE ONE THAT MATTERS, and it is the shape the facts +// beside it already use ([Config.ReasoningProfile], [Config.SupportsParameter], +// catalog.go's PriceNow). A catalog that has not loaded, a fetch that failed, a +// model nobody published modalities for — all of them are "nobody has said", and +// for a whole release that was spelled the same way as "this model is blind". +// The send gate was right to refuse on it. The substitution would have been +// wrong to fire on it, and when the substitution rewrote the transcript it was +// the one that could not be undone. +// +// A session wired with no oracle at all knows nothing, which refuses every send +// and hides no picture. +func (a *Agent) seesImages(model string) (sees, known bool) { if a.config.SeesImages == nil { - return SightUnknown + return false, false } return a.config.SeesImages(model) } diff --git a/internal/session/image.go b/internal/session/image.go index d1e378b1f2..342576a29e 100644 --- a/internal/session/image.go +++ b/internal/session/image.go @@ -105,15 +105,22 @@ func (a *Agent) SubmitImage(ctx context.Context, text string, images []Image) (< return a.Submit(ctx, text) } - // The gate reads the model the NEXT turn will ride, which is the model this - // message is about to be sent to. Reading it here rather than at assembly - // keeps the refusal cheap: nothing is read from disk for a model that could - // not have looked at it, and the fallback's own resolution is cheaper still. - model := a.Model() + // THE GATE READS THE MODEL THE WORK IS ON ([Agent.ridingNowLocked]), which is + // the model this picture is about to be sent to — the same model the request + // guard beside it asks about (blindswap.go) and the same one the chrome is + // naming. Reading the dial here instead meant that a picture attached while a + // turn ran on another model was graded against a model that was not going to + // see it, in both directions: refused for a blind dial while the work could + // see, and accepted for a seeing dial while the work could not. + // + // Reading it here rather than at assembly keeps the refusal cheap: nothing is + // read from disk for a model that could not have looked at it, and the + // fallback's own resolution is cheaper still. + model := a.ridingNow() // A POSITIVE `sees` OR NOTHING. Unknown goes to the looking model exactly as // blind does: neither is a model this build may hand base64 to - // ([ModelSight]). - if a.sightOf(model) != SightSees { + // ([Config.SeesImages]). + if sees, known := a.seesImages(model); !sees || !known { return a.visionTurn(ctx, text, images, model) } @@ -410,7 +417,7 @@ func (a *Agent) startVisionTurnLocked(ctx context.Context, kept userMessage, liv // names the model the engine is on ([Agent.TurnModel]), and for the length of // a vision turn the engine is on the seer — which is also what the reply is // prefixed with, so the line and the reply agree. - a.ridesOnLocked(seer) + a.riding = seer hub := newEventHub() a.hub = hub turnCtx, cancel := context.WithCancelCause(ctx) diff --git a/internal/session/reasoning.go b/internal/session/reasoning.go index 0c14a69807..beb2b39540 100644 --- a/internal/session/reasoning.go +++ b/internal/session/reasoning.go @@ -188,11 +188,26 @@ func (a *Agent) recordAssistant(message ai.Message, reasoning provider.MessageRe // snapshotWithReasoning takes both aligned slices under one lock. THE SIDECAR // ALWAYS HAS THE TRANSCRIPT'S LENGTH, so no concurrent append can put model // working beside the wrong assistant message. +// +// AND IT IS THE ONE PLACE A REQUEST IS ASSEMBLED FROM THE TRANSCRIPT, which is +// why the picture guard is here and nowhere else (blindswap.go states the whole +// design). The messages that go out to a model known to be blind carry each +// picture's placeholder; the transcript keeps the bytes, so a hop onto a +// fallback that cannot see — which nobody asked for — cannot cost anybody a +// screenshot, and hopping back shows it again. +// +// THE MODEL IS THE ONE THE WORK IS TALKING TO ([Agent.ridingNowLocked]) rather +// than an argument, because a request is assembled after the boundary has taken +// the person's word and after a rescue has moved the step: the field IS which +// model this request is for, and a second copy of that fact passed down the call +// chain is a second thing to get wrong. func (a *Agent) snapshotWithReasoning() ([]ai.Message, []provider.MessageReasoning) { a.mu.Lock() defer a.mu.Unlock() a.alignReasoningLocked() messages := append([]ai.Message(nil), a.messages...) + sees, known := a.seesImages(a.ridingNowLocked()) + messages = a.blindSafe(messages, sees, known) hasReasoning := false for _, carried := range a.messageReasoning { if carried.Text != "" || len(carried.Details) > 0 { diff --git a/internal/session/session.go b/internal/session/session.go index 0f3a82bbaf..745552c03e 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -1278,26 +1278,29 @@ type Config struct { // model — roles.Resolve's floor, not a failure. RolesSource func(key string) (string, bool) - // SeesImages reports what this build KNOWS about a model's eyes, in three - // answers and not two ([ModelSight]). NIL IS [SightUnknown], which is the - // honest reading of a caller that holds no catalog. - // - // TWO DECISIONS READ IT AND THEY READ IT DIFFERENTLY, which is the whole - // reason it is not a bool. Sending pictures needs a positive YES: a model - // that cannot see answers a message full of image parts with a 400 or, worse, - // with a confident description of nothing, so [Agent.SubmitImage] refuses on - // anything but [SightSees]. Taking pictures OUT of a live transcript needs a - // positive NO: the scrub is one-way and the bytes do not come back - // (blindswap.go), so it runs on [SightBlind] and on nothing else. Spelled as - // one bool those two were forced to share a reading, and the shared reading - // was "unknown counts as blind" — which quietly destroyed a person's - // screenshot whenever a catalog fetch failed and the same model id answered - // differently than it had a minute earlier. + // SeesImages answers whether a model can read a picture, and whether anybody + // has said either way — the shape its neighbours below use, and for the same + // reason they use it ([Config.SupportsParameter], [Config.ReasoningProfile]). + // + // THE TWO CALLERS READ THE SAME ANSWER IN OPPOSITE DIRECTIONS, which is why + // "nobody knows" has to be sayable. The send gate needs a positive YES: it + // refuses to hand base64 to a model nobody has vouched for, and that costs a + // person one turn and a sentence. The wire guard needs a positive NO: it + // hides a conversation's pictures from a model known to be blind, and hiding + // them from one that can see perfectly well is a worse answer than sending + // them. Spelled as one bool the two were forced to share a reading, and the + // shared reading was "unknown counts as blind" — which, while that guard + // still rewrote the transcript, destroyed a person's screenshot whenever a + // catalog fetch failed and the same model id answered differently than it had + // a minute earlier. // // It is a function of the model rather than a value because the model moves: - // /model swaps it mid-session (see [Agent.SetModel]), and the answer has to - // follow the model the next turn will actually ride. - SeesImages func(model string) ModelSight + // a pick or a rescue changes what the work is talking to mid-turn, and the + // answer has to follow the model the next request is for. + // + // IT MUST NOT BLOCK, like every other row here: it is asked while a request + // is assembled, under the lock a surface repaints against. + SeesImages func(model string) (sees, known bool) // ReasoningProfile is the row's account of a model's thinking pass — // whether it can be turned off, which effort words it takes — under the @@ -2566,14 +2569,6 @@ type Agent struct { // session that has never opened an episode. writes *writeMeter running bool - // scrubbedFor is the model this transcript's pictures were last made safe - // for ([Agent.scrubBlindImagePartsLocked]). It is the memory that turns the - // scrub from something every turn does into something A SWAP does: empty - // until the first turn, and thereafter the id of whoever the pictures were - // last checked against. A conversation that never changes model therefore - // pays the check once, and an oracle that changes its mind about one id - // cannot reach back into a transcript nobody swapped. - scrubbedFor string // turnFloor is where the running turn's WORK begins in a.messages: the // index just past the message that opened the turn, stamped by // [Agent.startTurnLocked] and meaningful only while running is true. It is @@ -2637,10 +2632,14 @@ type Agent struct { // 2026-09-11 a pick made three seconds after a message left the // conversation's chrome naming a model no request had been sent to for // seventeen minutes. It is written at the turn's own opening and moved only - // by a move the work actually made, through the one door that also makes the - // pictures safe ([Agent.ridesOnLocked]), and it is NEVER CLEARED: `running` - // is the flag that says it means anything, and two flags to clear is one of - // them left set. + // by a move the work actually made, and it is NEVER CLEARED: `running` is the + // flag that says it means anything, and two flags to clear is one of them + // left set. + // + // AND IT IS WHAT EVERY REQUEST IS ASSEMBLED FOR. The picture guard asks what + // this model can see at the moment a request is built (blindswap.go), so a + // conversation's screenshots are hidden from a model that cannot read them + // and shown again the moment the work is back on one that can. riding string // recall is the pre-turn memory routing STARTED BESIDE THE TITLE and never // waited on (memory.go's [recallAside]). It is under mu because diff --git a/internal/session/sessionfile.go b/internal/session/sessionfile.go index 0a22878dfc..3848bfcb40 100644 --- a/internal/session/sessionfile.go +++ b/internal/session/sessionfile.go @@ -1228,30 +1228,6 @@ func (s *sessionFile) imagePath(part ai.ContentPart) string { return s.images[partKey(part)] } -// rememberImagePath indexes one part under a path this file already knows, which -// is what keeps a picture's NAME on a message whose bytes have been replaced by a -// placeholder ([Agent.scrubBlindImagePartsLocked]). A replay does the same thing -// by accident and for the same reason — [rememberParts] indexes whatever part -// was rebuilt, placeholder or picture — so a scrubbed message and a replayed one -// draw alike. -// -// An empty path records nothing: an index entry pointing nowhere would make -// [sessionFile.imageRefs] claim a picture it cannot name. -func (s *sessionFile) rememberImagePath(part ai.ContentPart, path string) { - if s == nil { - return - } - if path = strings.TrimSpace(path); path == "" { - return - } - s.mu.Lock() - defer s.mu.Unlock() - if s.images == nil { - s.images = make(map[string]string) - } - s.images[partKey(part)] = path -} - // isNote reports whether one message is a line the SESSION wrote — the answer // [shapeEntries] turns into the "note" role a surface draws in its own lane // rather than in the person's. diff --git a/internal/session/steer.go b/internal/session/steer.go index 0704ee6fde..57a16b5be3 100644 --- a/internal/session/steer.go +++ b/internal/session/steer.go @@ -486,39 +486,10 @@ func (a *Agent) latchTheModel() string { a.mu.Lock() defer a.mu.Unlock() a.spokenModel = "" - a.ridesOnLocked(a.model) + a.riding = a.model return a.model } -// ridesOnLocked publishes the model the work is talking to AND MAKES THE -// TRANSCRIPT'S PICTURES SAFE FOR IT, because those are one event and not two. -// -// THE SCRUB BELONGS TO THE MOVE, NOT TO THE PICKER. A conversation carrying -// attached images carries them as base64 in the live transcript and re-sends -// them with every request, so the moment that matters is the moment the work -// starts talking to a model that cannot see — whether that came from a person's -// pick taken at the request boundary, a rescue hopping onto a fallback, or a -// turn simply opening on the dial. [Agent.SetModel] scrubbing instead was the -// old shape and it was wrong at both ends: it scrubbed for a preference that -// may never be acted on, and under steer.go's law a pick can reach the REQUEST -// in flight, which the picker's scrub would have missed by one request. -// -// AND ONLY WHEN THE MODEL ACTUALLY MOVED. A conversation that has never changed -// model would otherwise ask the oracle on every turn, and that oracle can answer -// differently twice for one id — a catalog fetch that failed after a cached one -// succeeded turned `sees` into `blind` with nobody having touched the picker, -// and the second turn of that conversation ate the person's screenshot. THE -// MOVE IS THE SEAM, so the move — and nothing else — is what may scrub -// (blindswap.go states the rule and its three limits). -func (a *Agent) ridesOnLocked(model string) { - a.riding = model - if model == "" || strings.EqualFold(model, a.scrubbedFor) { - return - } - a.scrubBlindImagePartsLocked(model) - a.scrubbedFor = model -} - // rideModel publishes THE MODEL THE WORK IS ACTUALLY TALKING TO. The ladder has // always known it — it is the local the rescue chain moves — and until now // nothing outside the loop could read it, so the door that decides whether a @@ -535,7 +506,16 @@ func (a *Agent) ridesOnLocked(model string) { func (a *Agent) rideModel(model string) { a.mu.Lock() defer a.mu.Unlock() - a.ridesOnLocked(model) + a.riding = model +} + +// ridingNow is [Agent.ridingNowLocked] for the callers not already holding a.mu +// — the picture gate, which grades an attachment against the model that is +// about to be sent it (image.go). +func (a *Agent) ridingNow() string { + a.mu.Lock() + defer a.mu.Unlock() + return a.ridingNowLocked() } // ridingNowLocked is that fact when there is a step to have it, and the diff --git a/internal/session/tools_doc.go b/internal/session/tools_doc.go index 3a6de327cd..be1db72f5b 100644 --- a/internal/session/tools_doc.go +++ b/internal/session/tools_doc.go @@ -399,11 +399,11 @@ func (a *Agent) readDocument(ctx context.Context, path, question string, offset, // A session with no seer keeps its own model, and the rung fails the way it // always did: naming itself, in the words the person can act on. func (a *Agent) documentModel(kind documentKind) string { - model := a.Model() + model := a.ridingNow() if kind != documentImage { return model } - if a.sightOf(model) == SightSees { + if sees, known := a.seesImages(model); sees && known { return model } if seer := a.visionSeer(); seer != "" { From df7978f9a55b093e5eef76241ce5fbefdcaaaa3f Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sat, 12 Sep 2026 11:38:54 -0400 Subject: [PATCH 8/8] tests: the picture guard is asserted on the wire, because that is where it acts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every one of these read the transcript the instant a model was picked. The guard is a function of the request now, so they read what the completer was handed — and two of them assert the thing the rewrite could never do: an oracle that flaps back, and a move onto a model that sees, both show the picture again. And the oracle's own test stops asserting that a row which published NO input modalities is a known 'cannot see'. That row published nothing; grading it as a verdict was the door calling its own ignorance a fact. Co-Authored-By: Claude Opus 5 (1M context) --- cmd/aforge/chatv3_images_test.go | 75 ++++++++++------ cmd/aforge/chatv3_modelshelf_test.go | 2 +- internal/session/blindswap_test.go | 130 +++++++++++++++++---------- internal/session/image_test.go | 8 +- internal/session/tools_doc_test.go | 6 +- internal/session/tools_image_test.go | 8 +- internal/session/turnmodel_test.go | 115 ++++++++++++++++-------- internal/session/vision_slot_test.go | 4 +- internal/tui3/imagepaste_test.go | 7 +- 9 files changed, 231 insertions(+), 124 deletions(-) diff --git a/cmd/aforge/chatv3_images_test.go b/cmd/aforge/chatv3_images_test.go index 36514ce8fd..29ed507351 100644 --- a/cmd/aforge/chatv3_images_test.go +++ b/cmd/aforge/chatv3_images_test.go @@ -21,30 +21,35 @@ func TestTheVisionGateAnswersFromTheCatalogsInputModalities(t *testing.T) { models := fakeV3Catalog{rows: []catalog.Model{ {ID: "vendor/sees", InputModalities: []string{"text", "image"}, OutputModalities: []string{"text"}}, {ID: "vendor/reads", InputModalities: []string{"text"}, OutputModalities: []string{"text"}}, - // A row cached before modalities were recorded. Silence is not consent. + // A row cached before modalities were recorded. Silence is not consent — + // and it is not a verdict either. {ID: "vendor/quiet", OutputModalities: []string{"text"}}, }} cfg := session.Config{Model: "vendor/sees", SeesImages: v3SeesImages(models)} - if got := cfg.SeesImages("vendor/sees"); got != session.SightSees { + if got := sightWord(cfg.SeesImages("vendor/sees")); got != "sees" { t.Fatalf("a model that publishes image input reads as %v", got) } // Case is not a difference: it is the same id either way. - if got := cfg.SeesImages("Vendor/Sees"); got != session.SightSees { + if got := sightWord(cfg.SeesImages("Vendor/Sees")); got != "sees" { t.Fatalf("the gate answered case rather than identity: %v", got) } - // A ROW THIS DOOR HAS READ IS A VERDICT; AN ID IT HAS NOT IS IGNORANCE. The - // two were one answer until 2026-09-12, and the scrub that read them took a - // person's pictures out of the transcript on the strength of the second - // (internal/session's blindswap.go). - for _, model := range []string{"vendor/reads", "vendor/quiet"} { - if got := cfg.SeesImages(model); got != session.SightBlind { - t.Fatalf("%q, whose row this door has read, answers %v", model, got) - } + // A ROW THAT PUBLISHED ITS MODALITIES IS A VERDICT. `vendor/reads` says text + // and stops: this door has read that and knows the model is blind. + if got := sightWord(cfg.SeesImages("vendor/reads")); got != "cannot see" { + t.Fatalf("a row that published text-only input answers %v", got) } - for _, model := range []string{"vendor/unheard-of", ""} { - if got := cfg.SeesImages(model); got != session.SightUnknown { - t.Fatalf("%q, which this door has never read, answers %v rather than ignorance", model, got) + // AND EVERYTHING ELSE IS IGNORANCE, INCLUDING A ROW THAT SAID NOTHING. + // `vendor/quiet` published no input modalities at all, which is the shape a + // router uses for a model with no architecture block — not a claim that it + // cannot see. Grading that silence as a verdict was this door calling its own + // ignorance a fact, and it was the irreversible one of the two answers for as + // long as the guard that read it rewrote the transcript (internal/session's + // blindswap.go). The SEND gate still refuses all three of these, which is the + // silence law; it refuses them for not being a positive yes. + for _, model := range []string{"vendor/quiet", "vendor/unheard-of", ""} { + if got := sightWord(cfg.SeesImages(model)); got != "nobody has said" { + t.Fatalf("%q, which nobody has published anything about, answers %v rather than ignorance", model, got) } } @@ -52,10 +57,10 @@ func TestTheVisionGateAnswersFromTheCatalogsInputModalities(t *testing.T) { // answers per call, so the same session says yes once the rows land rather // than being pinned at boot. warming := fakeV3Catalog{} - if got := v3SeesImages(warming)("vendor/sees"); got != session.SightUnknown { + if got := sightWord(v3SeesImages(warming)("vendor/sees")); got != "nobody has said" { t.Fatalf("a warming catalog answered %v for a model it has not read", got) } - if got := v3SeesImages(nil)("vendor/sees"); got != session.SightUnknown { + if got := sightWord(v3SeesImages(nil)("vendor/sees")); got != "nobody has said" { t.Fatalf("no catalog at all answered %v for a model", got) } } @@ -78,16 +83,16 @@ func TestTheVisionGateReadsTheDiskCacheWhileTheCatalogIsWarming(t *testing.T) { } gate := v3SeesImages(fakeV3Catalog{}) - if got := gate("vendor/sees"); got != session.SightSees { + if got := sightWord(gate("vendor/sees")); got != "sees" { t.Fatalf("the cache's own witness that a model can see reads as %v", got) } for _, model := range []string{"vendor/reads", "vendor/painter"} { - if got := gate(model); got != session.SightBlind { + if got := sightWord(gate(model)); got != "cannot see" { t.Fatalf("%q, whose cached row this door has read, answers %v", model, got) } } // AND A MODEL THE CACHE HAS NEVER HEARD OF IS IGNORANCE, not a verdict. - if got := gate("vendor/unheard-of"); got != session.SightUnknown { + if got := sightWord(gate("vendor/unheard-of")); got != "nobody has said" { t.Fatalf("a model the cache does not carry answers %v rather than ignorance", got) } @@ -97,18 +102,38 @@ func TestTheVisionGateReadsTheDiskCacheWhileTheCatalogIsWarming(t *testing.T) { live := v3SeesImages(fakeV3Catalog{rows: []catalog.Model{ {ID: "vendor/sees", InputModalities: []string{"text"}, OutputModalities: []string{"text"}}, }}) - if got := live("vendor/sees"); got != session.SightBlind { + if got := sightWord(live("vendor/sees")); got != "cannot see" { t.Fatalf("a stale cache overruled the catalog that had already answered: %v", got) } } // The modality test itself, spelled out: the direction that matters is INPUT, -// and an output-image row is a painter rather than a model that can see. +// an output-image row is a painter rather than a model that can see, and a row +// that listed nothing has said nothing. func TestTheImageModalityTestIsAboutWhatGoesIn(t *testing.T) { - if !v3ReadsImages([]string{"text", "IMAGE"}) { - t.Fatal("a published image modality was missed on case") + if got := sightWord(v3ReadsImages([]string{"text", "IMAGE"})); got != "sees" { + t.Fatalf("a published image modality was missed on case: %v", got) + } + if got := sightWord(v3ReadsImages([]string{"text", "audio"})); got != "cannot see" { + t.Fatalf("a row that listed its input and left image out answers %v", got) + } + // AN EMPTY LIST IS THE THIRD ANSWER. It is a row with no architecture block, + // and it is the difference between refusing to send a picture — which costs a + // turn — and hiding one, which used to cost the picture. + if got := sightWord(v3ReadsImages(nil)); got != "nobody has said" { + t.Fatalf("a row that published no modalities answers %v rather than ignorance", got) } - if v3ReadsImages(nil) || v3ReadsImages([]string{"text", "audio"}) { - t.Fatal("a row with no image input was read as one that has it") +} + +// sightWord is an oracle's two answers as one, so a table can say what it +// expected in the words the design uses. +func sightWord(sees, known bool) string { + switch { + case !known: + return "nobody has said" + case sees: + return "sees" + default: + return "cannot see" } } diff --git a/cmd/aforge/chatv3_modelshelf_test.go b/cmd/aforge/chatv3_modelshelf_test.go index f5ee91a754..30b8688e6a 100644 --- a/cmd/aforge/chatv3_modelshelf_test.go +++ b/cmd/aforge/chatv3_modelshelf_test.go @@ -189,7 +189,7 @@ func TestTheShelfTakesTodaysListAndKeepsYesterdaysOnFailure(t *testing.T) { if err != nil || len(rows) != 2 || at.IsZero() { t.Fatalf("a landed refresh answered %d rows at %v, error %v", len(rows), at, err) } - if got := v3SeesImages(live)("vendor/shipped-this-morning"); got != session.SightSees { + if got := sightWord(v3SeesImages(live)("vendor/shipped-this-morning")); got != "sees" { t.Fatalf("the vision gate reads the model the refresh brought as %v", got) } cached := tui3.CachedModels() diff --git a/internal/session/blindswap_test.go b/internal/session/blindswap_test.go index de2b677027..8f42d599d9 100644 --- a/internal/session/blindswap_test.go +++ b/internal/session/blindswap_test.go @@ -41,23 +41,22 @@ func countImageParts(agent *Agent) int { return count } -// Moving onto a model without eyes turns the pictures into their placeholders — -// once, naming the file, with the person's own words left alone. +// A model without eyes is SENT each picture's placeholder — once, naming the +// file, with the person's own words left alone — and the transcript keeps the +// picture. // -// THE MOVE IS WHAT DOES IT, NOT THE PICK. A pick is a preference and may never -// be acted on; what may not happen is a request going to a model that cannot see -// with the base64 still in it. So the scrub rides the one door the model the -// work is talking to changes through (steer.go's [Agent.ridesOnLocked]), and -// this drives a turn after the pick rather than reading the transcript the -// instant the picker closes. -func TestSetModelScrubsImagePartsForAModelThatCannotSee(t *testing.T) { - completer := &scriptedCompleter{steps: []step{ - func(context.Context, []ai.Message) (*ai.Response, error) { return textResponse("seen"), nil }, - func(context.Context, []ai.Message) (*ai.Response, error) { return textResponse("blind"), nil }, - func(context.Context, []ai.Message) (*ai.Response, error) { return textResponse("still blind"), nil }, - }} +// THE REQUEST IS WHERE THIS HAPPENS, not the transcript (blindswap.go states +// the whole design), so what the assertion reads is what the completer was +// handed. +func TestABlindModelIsSentThePlaceholderAndTheTranscriptKeepsThePicture(t *testing.T) { + sent := make(chan []ai.Message, 2) + answer := func(_ context.Context, messages []ai.Message) (*ai.Response, error) { + sent <- append([]ai.Message(nil), messages...) + return textResponse("read"), nil + } + completer := &scriptedCompleter{steps: []step{answer, answer, answer}} agent, workspace := newTestAgent(t, completer, func(config *Config) { - config.SeesImages = func(model string) ModelSight { return SightOf(model == "test/model") } + config.SeesImages = func(model string) (bool, bool) { return seesIf(model == "test/model") } config.SessionFile = writeableJournal(t) }) path := writeImage(t, workspace, "chart.png", "PHOTOBYTES") @@ -65,21 +64,17 @@ func TestSetModelScrubsImagePartsForAModelThatCannotSee(t *testing.T) { ctx, cancel := deadline(10 * time.Second) defer cancel() collect(t, mustSubmitImage(t, agent, ctx, "what is wrong with this?", []Image{{Path: path}})) + <-sent if countImageParts(agent) != 1 { t.Fatalf("the picture never reached the transcript") } agent.SetModel("vendor/blind") - if count := countImageParts(agent); count != 1 { - t.Fatalf("the pick alone took %d pictures away before any request went to the blind model", 1-count) - } collect(t, mustSubmit(t, agent, "and now?")) - if count := countImageParts(agent); count != 0 { - t.Fatalf("%d image parts survived the move onto a blind model", count) - } - text := transcriptText(agent) + messages := <-sent + text := messagesText(messages) if !strings.Contains(text, "[image "+path+" — this model cannot see images]") { - t.Fatalf("the placeholder does not name the file:\n%s", text) + t.Fatalf("the placeholder the blind model was sent does not name the file:\n%s", text) } if !strings.Contains(text, "what is wrong with this?") { t.Fatalf("the person's own words were lost:\n%s", text) @@ -87,13 +82,17 @@ func TestSetModelScrubsImagePartsForAModelThatCannotSee(t *testing.T) { if strings.Count(text, "— this model cannot see images]") != 1 { t.Fatalf("the picture was replaced more than once:\n%s", text) } - - // Idempotent: a second swap between blind models rewrites nothing, because - // there is nothing left to rewrite. - agent.SetModel("vendor/blind-two") - collect(t, mustSubmit(t, agent, "and again?")) - if again := transcriptText(agent); !strings.Contains(again, text) { - t.Fatalf("a second move rewrote what the first one left:\n%s", again) + pictures := 0 + for _, message := range messages { + pictures += len(imagePartURLs(message)) + } + if pictures != 0 { + t.Fatalf("the blind model was sent %d pictures", pictures) + } + // AND THE BYTES ARE STILL THERE, which is what makes every one of these + // answers reversible. + if got := countImageParts(agent); got != 1 { + t.Fatalf("the transcript holds %d pictures, want the one that was attached", got) } } @@ -147,8 +146,12 @@ func TestTheScrubLeavesTheJournalWhole(t *testing.T) { } // And the resume half of the same seam: a session reopened on a model that -// cannot see rebuilds the placeholder, never the bytes. -func TestResumingOnABlindModelScrubsTheReplayedPictures(t *testing.T) { +// cannot see KEEPS the replayed pictures and sends their placeholders. +// +// The rewrite used to happen once, at the door, against whatever model the agent +// happened to open with — so reopening on a blind model and then moving off it +// left a conversation whose pictures were gone for no reason anybody could see. +func TestResumingOnABlindModelKeepsThePicturesAndSendsPlaceholders(t *testing.T) { journal := writeableJournal(t) completer := &scriptedCompleter{} agent, workspace := newTestAgent(t, completer, func(config *Config) { @@ -164,35 +167,57 @@ func TestResumingOnABlindModelScrubsTheReplayedPictures(t *testing.T) { t.Fatalf("Close: %v", err) } + sent := make(chan []ai.Message, 2) + answer := func(_ context.Context, messages []ai.Message) (*ai.Response, error) { + sent <- append([]ai.Message(nil), messages...) + return textResponse("read"), nil + } resumed, err := newAgent(Config{ Workspace: workspace, Model: "vendor/blind", System: "SYSTEM", SessionFile: journal, - SeesImages: func(model string) ModelSight { return SightOf(model == "test/model") }, - }, &scriptedCompleter{}) + SeesImages: func(model string) (bool, bool) { return seesIf(model == "test/model") }, + }, &scriptedCompleter{steps: []step{answer, answer}}) if err != nil { t.Fatalf("resume: %v", err) } t.Cleanup(func() { _ = resumed.Close() }) - if count := countImageParts(resumed); count != 0 { - t.Fatalf("%d image parts were replayed onto a blind model", count) + if count := countImageParts(resumed); count != 1 { + t.Fatalf("the replay left %d pictures in the transcript, want the one the journal holds", count) } - if text := transcriptText(resumed); !strings.Contains(text, "[image "+path+" — this model cannot see images]") { - t.Fatalf("the resumed transcript does not name the picture:\n%s", text) + collect(t, mustSubmit(t, resumed, "and now?")) + text := messagesText(<-sent) + if !strings.Contains(text, "[image "+path+" — this model cannot see images]") { + t.Fatalf("the blind model was not sent the placeholder:\n%s", text) + } + + // AND MOVING ONTO A MODEL THAT SEES SHOWS THEM AGAIN. + resumed.SetModel("test/model") + collect(t, mustSubmit(t, resumed, "look again")) + pictures := 0 + for _, message := range <-sent { + pictures += len(imagePartURLs(message)) + } + if pictures != 1 { + t.Fatalf("the model that can see was sent %d pictures after the resume", pictures) } } -// A picture the journal cannot name still loses its bytes — a memory-only -// session has no file to have written a path, and "no path" is not a reason to -// send base64 to a blind model. -func TestTheScrubDropsBytesEvenWithNoJournaledPath(t *testing.T) { +// A picture the journal cannot name is still kept out of a blind model's +// request — a memory-only session has no file to have written a path, and "no +// path" is not a reason to send base64 to a model that cannot read it. +func TestAPictureWithNoJournaledPathIsStillHeldBack(t *testing.T) { + sent := make(chan []ai.Message, 1) completer := &scriptedCompleter{steps: []step{ - func(context.Context, []ai.Message) (*ai.Response, error) { return textResponse("blind"), nil }, + func(_ context.Context, messages []ai.Message) (*ai.Response, error) { + sent <- append([]ai.Message(nil), messages...) + return textResponse("blind"), nil + }, }} agent, _ := newTestAgent(t, completer, func(config *Config) { - config.SeesImages = func(model string) ModelSight { return SightOf(model == "test/model") } + config.SeesImages = func(model string) (bool, bool) { return seesIf(model == "test/model") } }) agent.mu.Lock() agent.messages = append(agent.messages, ai.Message{Role: "user", Content: []ai.ContentPart{ @@ -201,13 +226,22 @@ func TestTheScrubDropsBytesEvenWithNoJournaledPath(t *testing.T) { }}) agent.mu.Unlock() - // The move is what scrubs, so the turn after the pick is what this asserts on. agent.SetModel("vendor/blind") collect(t, mustSubmit(t, agent, "and now?")) - if count := countImageParts(agent); count != 0 { - t.Fatalf("%d image parts survived", count) + messages := <-sent + pictures := 0 + for _, message := range messages { + pictures += len(imagePartURLs(message)) } - if text := transcriptText(agent); !strings.Contains(text, "[image — this model cannot see images]") { + if pictures != 0 { + t.Fatalf("%d pictures went to the blind model", pictures) + } + if text := messagesText(messages); !strings.Contains(text, "[image — this model cannot see images]") { t.Fatalf("the placeholder is missing:\n%s", text) } } + +// seesIf is a fixture's yes-or-no as the oracle's pair ([Config.SeesImages]): a +// test that says a model sees or does not is a test that KNOWS, and the third +// answer — nobody has said — is written out where it is the subject. +func seesIf(sees bool) (bool, bool) { return sees, true } diff --git a/internal/session/image_test.go b/internal/session/image_test.go index 646efc6e69..18124cf4ee 100644 --- a/internal/session/image_test.go +++ b/internal/session/image_test.go @@ -36,7 +36,7 @@ func noGate(*Config) {} // withVision is the gate saying yes. func withVision(config *Config) { - config.SeesImages = func(string) ModelSight { return SightOf(true) } + config.SeesImages = func(string) (bool, bool) { return seesIf(true) } } func imagePartURLs(message ai.Message) []string { @@ -148,7 +148,7 @@ func TestSubmitImageRefusesAModelThatCannotSeeAndNamesIt(t *testing.T) { // that holds no catalog must not be able to send parts to a model // nobody has vouched for. {"nil gate", noGate}, - {"gate says no", func(config *Config) { config.SeesImages = func(string) ModelSight { return SightOf(false) } }}, + {"gate says no", func(config *Config) { config.SeesImages = func(string) (bool, bool) { return seesIf(false) } }}, } { t.Run(testCase.name, func(t *testing.T) { completer := &scriptedCompleter{} @@ -186,7 +186,7 @@ func TestSubmitImageRefusesAModelThatCannotSeeAndNamesIt(t *testing.T) { func TestSubmitImageGateFollowsTheCurrentModel(t *testing.T) { completer := &scriptedCompleter{} agent, workspace := newTestAgent(t, completer, func(config *Config) { - config.SeesImages = func(model string) ModelSight { return SightOf(model == "vendor/sees") } + config.SeesImages = func(model string) (bool, bool) { return seesIf(model == "vendor/sees") } }) path := writeImage(t, workspace, "shot.png", "BYTES") @@ -499,7 +499,7 @@ func TestImagePartsReachTheWire(t *testing.T) { APIKey: "test", BaseURL: server.URL, System: "SYSTEM", - SeesImages: func(string) ModelSight { return SightOf(true) }, + SeesImages: func(string) (bool, bool) { return seesIf(true) }, }) if err != nil { t.Fatalf("New: %v", err) diff --git a/internal/session/tools_doc_test.go b/internal/session/tools_doc_test.go index 66a8a3e18a..d3d14b1d99 100644 --- a/internal/session/tools_doc_test.go +++ b/internal/session/tools_doc_test.go @@ -497,7 +497,7 @@ func TestAPictureIsReadByTheLookingModelWhenTheSessionCannotSee(t *testing.T) { provider.DocumentParseNative: "INVOICE 3319 — total 412.00", }} agent, workspace := newDocumentAgent(t, parser, nil, func(config *Config) { - config.SeesImages = func(model string) ModelSight { return SightOf(model == "vendor/slot-eyes") } + config.SeesImages = func(model string) (bool, bool) { return seesIf(model == "vendor/slot-eyes") } config.MediaModel = func(modality string) string { if modality == "vision" { return "vendor/slot-eyes" @@ -524,7 +524,7 @@ func TestAPictureStaysOnTheSessionModelWhenItCanSee(t *testing.T) { provider.DocumentParseNative: "INVOICE 3319 — total 412.00", }} agent, workspace := newDocumentAgent(t, parser, nil, func(config *Config) { - config.SeesImages = func(string) ModelSight { return SightOf(true) } + config.SeesImages = func(string) (bool, bool) { return seesIf(true) } config.MediaModel = func(string) string { return "vendor/slot-eyes" } }) name := dropFile(t, workspace, "receipt.png", []byte("\x89PNG\r\n\x1a\n")) @@ -544,7 +544,7 @@ func TestADocumentIsNeverRoutedToTheLookingModel(t *testing.T) { provider.DocumentParseNative: "PAGE ONE — the quarterly figures, in full sentences", }} agent, workspace := newDocumentAgent(t, parser, nil, func(config *Config) { - config.SeesImages = func(string) ModelSight { return SightOf(false) } + config.SeesImages = func(string) (bool, bool) { return seesIf(false) } config.MediaModel = func(string) string { return "vendor/slot-eyes" } }) name := dropFile(t, workspace, "scan.pdf", scannedPDF()) diff --git a/internal/session/tools_image_test.go b/internal/session/tools_image_test.go index 7c09e7bf78..533cfb1ad5 100644 --- a/internal/session/tools_image_test.go +++ b/internal/session/tools_image_test.go @@ -338,7 +338,7 @@ func TestGenerateImageUsageFoldsIntoTheSessionTotal(t *testing.T) { // blindWithVision is a session whose model cannot see and whose vision role // resolves to one that can. func blindWithVision(config *Config) { - config.SeesImages = func(string) ModelSight { return SightOf(false) } + config.SeesImages = func(string) (bool, bool) { return seesIf(false) } config.RolesSource = pinnedSource(map[string]string{ roles.PinKey(roles.RoleVision): "vendor/eyes", }) @@ -475,16 +475,16 @@ func TestVisionFallbackRefusesOnlyWhenNoVisionModelResolves(t *testing.T) { mutate func(*Config) }{ {"no settings at all", func(config *Config) { - config.SeesImages = func(string) ModelSight { return SightOf(false) } + config.SeesImages = func(string) (bool, bool) { return seesIf(false) } }}, {"settings with nothing for vision", func(config *Config) { - config.SeesImages = func(string) ModelSight { return SightOf(false) } + config.SeesImages = func(string) (bool, bool) { return seesIf(false) } config.RolesSource = pinnedSource(map[string]string{"roles.title": "cheap/model"}) }}, // A tier pointed at the model we just established cannot see is a // configuration, not a capability. {"vision resolves to the blind model itself", func(config *Config) { - config.SeesImages = func(string) ModelSight { return SightOf(false) } + config.SeesImages = func(string) (bool, bool) { return seesIf(false) } config.RolesSource = pinnedSource(map[string]string{ roles.TierKey(roles.TierHigh): "vendor/blind", }) diff --git a/internal/session/turnmodel_test.go b/internal/session/turnmodel_test.go index 235b44e922..cc45328509 100644 --- a/internal/session/turnmodel_test.go +++ b/internal/session/turnmodel_test.go @@ -45,7 +45,7 @@ func TestAPickWhileAnAnswerIsArrivingLeavesTheWorkWhereItIs(t *testing.T) { }, }} agent, workspace := newTestAgent(t, completer, func(config *Config) { - config.SeesImages = func(model string) ModelSight { return SightOf(model == "test/model") } + config.SeesImages = func(model string) (bool, bool) { return seesIf(model == "test/model") } config.SessionFile = writeableJournal(t) }) phases := watchPhases(t) @@ -125,7 +125,7 @@ func TestAPickThatCutsTheRequestTakesThePicturesWithIt(t *testing.T) { }, }} agent, workspace := newTestAgent(t, completer, func(config *Config) { - config.SeesImages = func(model string) ModelSight { return SightOf(model == "test/model") } + config.SeesImages = func(model string) (bool, bool) { return seesIf(model == "test/model") } config.SessionFile = writeableJournal(t) }) path := writeImage(t, workspace, "chart.png", "PHOTOBYTES") @@ -185,19 +185,23 @@ func TestAnIdleSessionsNewsNamesTheDial(t *testing.T) { } } -// A TURN THAT SWAPPED NOTHING TAKES NOTHING AWAY. The scrub is one-way and the -// bytes do not come back, so it fires on a swap and on nothing else: an oracle -// that answers `sees` off a warm cache and `blind` a minute later off a catalog -// fetch that failed used to be enough to eat a person's screenshot with nobody -// having touched the picker. -func TestAFlappingOracleCannotScrubAConversationThatNeverSwapped(t *testing.T) { - sight := SightSees - completer := &scriptedCompleter{steps: []step{ - func(context.Context, []ai.Message) (*ai.Response, error) { return textResponse("first"), nil }, - func(context.Context, []ai.Message) (*ai.Response, error) { return textResponse("second"), nil }, - }} +// THE GUARD IS A FUNCTION OF THE REQUEST, SO NOTHING IT DOES IS PERMANENT. +// +// An oracle that answers `sees` off a warm cache and `blind` a minute later off +// a catalog fetch that failed used to be enough to eat a person's screenshot +// with nobody having touched the picker. Now the same flap hides the picture +// from one request and shows it on the next, because the only thing that ever +// happens to the bytes is that a copy of the messages leaves without them. +func TestAFlappingOracleHidesAPictureAndThenShowsItAgain(t *testing.T) { + sees := true + sent := make(chan []ai.Message, 3) + answer := func(_ context.Context, messages []ai.Message) (*ai.Response, error) { + sent <- append([]ai.Message(nil), messages...) + return textResponse("read"), nil + } + completer := &scriptedCompleter{steps: []step{answer, answer, answer}} agent, workspace := newTestAgent(t, completer, func(config *Config) { - config.SeesImages = func(string) ModelSight { return sight } + config.SeesImages = func(string) (bool, bool) { return sees, true } config.SessionFile = writeableJournal(t) }) path := writeImage(t, workspace, "chart.png", "PHOTOBYTES") @@ -208,33 +212,51 @@ func TestAFlappingOracleCannotScrubAConversationThatNeverSwapped(t *testing.T) { if got := countImageParts(agent); got != 1 { t.Fatalf("the picture never reached the transcript") } + if got := picturesIn(t, sent); got != 1 { + t.Fatalf("the model that can see was sent %d pictures", got) + } - // The catalog goes cold under the same model id. Nothing was swapped. - sight = SightBlind + // The catalog goes cold under the same model id, and nothing was swapped. + sees = false collect(t, mustSubmit(t, agent, "and now?")) + if got := picturesIn(t, sent); got != 0 { + t.Fatalf("a model this build now believes is blind was sent %d pictures", got) + } if got := countImageParts(agent); got != 1 { - t.Fatalf("a turn on the SAME model took the pictures away: %d left", got) + t.Fatalf("hiding the picture from one request took it out of the transcript: %d left", got) + } + + // AND THE FLAP BACK COSTS NOTHING, which is the whole difference from the + // rewrite this replaced. A rescue hop onto a blind fallback — which nobody + // asked for — used to destroy the pictures of a conversation whose own model + // could see them perfectly well. + sees = true + collect(t, mustSubmit(t, agent, "and again?")) + if got := picturesIn(t, sent); got != 1 { + t.Fatalf("the model that can see again was sent %d pictures", got) } } -// AND AN UNKNOWN MODEL IS NOT A BLIND ONE. A swap onto a model this build has -// never read a row for is ignorance, and ignorance may refuse to send but may -// never destroy. -func TestASwapOntoAnUnknownModelKeepsThePictures(t *testing.T) { - completer := &scriptedCompleter{steps: []step{ - func(context.Context, []ai.Message) (*ai.Response, error) { return textResponse("first"), nil }, - func(context.Context, []ai.Message) (*ai.Response, error) { return textResponse("second"), nil }, - func(context.Context, []ai.Message) (*ai.Response, error) { return textResponse("third"), nil }, - }} +// AND A MODEL NOBODY HAS VOUCHED FOR IS SENT THE CONVERSATION AS IT STANDS, +// while one this build KNOWS is blind is sent the placeholder. Ignorance may +// refuse a new attachment — that costs a turn and a sentence — but it may not +// decide what an existing picture is worth. +func TestOnlyAKnownBlindModelIsSentThePlaceholder(t *testing.T) { + sent := make(chan []ai.Message, 3) + answer := func(_ context.Context, messages []ai.Message) (*ai.Response, error) { + sent <- append([]ai.Message(nil), messages...) + return textResponse("read"), nil + } + completer := &scriptedCompleter{steps: []step{answer, answer, answer}} agent, workspace := newTestAgent(t, completer, func(config *Config) { - config.SeesImages = func(model string) ModelSight { + config.SeesImages = func(model string) (bool, bool) { switch model { case "test/model": - return SightSees + return true, true case "vendor/blind": - return SightBlind + return false, true } - return SightUnknown + return false, false } config.SessionFile = writeableJournal(t) }) @@ -243,18 +265,39 @@ func TestASwapOntoAnUnknownModelKeepsThePictures(t *testing.T) { ctx, cancel := deadline(20 * time.Second) defer cancel() collect(t, mustSubmitImage(t, agent, ctx, "what is wrong with this?", []Image{{Path: path}})) + if got := picturesIn(t, sent); got != 1 { + t.Fatalf("the seeing model was sent %d pictures", got) + } agent.SetModel("vendor/never-heard-of") collect(t, mustSubmit(t, agent, "still there?")) - if got := countImageParts(agent); got != 1 { - t.Fatalf("a swap onto a model nobody has vouched for destroyed %d pictures", 1-got) + if got := picturesIn(t, sent); got != 1 { + t.Fatalf("a model nobody has vouched for was sent %d pictures, want the one that is there", got) } - // AND A SWAP ONTO A MODEL THIS BUILD KNOWS IS BLIND STILL SCRUBS, which is - // the capability this guard must not have taken away. agent.SetModel("vendor/blind") collect(t, mustSubmit(t, agent, "and now?")) - if got := countImageParts(agent); got != 0 { - t.Fatalf("a swap onto a known-blind model left %d pictures", got) + if got := picturesIn(t, sent); got != 0 { + t.Fatalf("a model this build knows is blind was sent %d pictures", got) + } + // AND THE TRANSCRIPT NEVER MOVED THROUGH ANY OF IT. + if got := countImageParts(agent); got != 1 { + t.Fatalf("the transcript holds %d pictures, want the one that was attached", got) + } +} + +// picturesIn is how many pictures the request that just went out carried. +func picturesIn(t *testing.T, sent chan []ai.Message) int { + t.Helper() + select { + case messages := <-sent: + pictures := 0 + for _, message := range messages { + pictures += len(imagePartURLs(message)) + } + return pictures + case <-time.After(5 * time.Second): + t.Fatal("no request reached the completer") + return -1 } } diff --git a/internal/session/vision_slot_test.go b/internal/session/vision_slot_test.go index 3a7b54697e..63493e75f8 100644 --- a/internal/session/vision_slot_test.go +++ b/internal/session/vision_slot_test.go @@ -83,7 +83,7 @@ func TestWithNoResolverTheRolesLadderStillAnswers(t *testing.T) { func TestTheVisionFallbackRidesTheSlot(t *testing.T) { completer := &scriptedCompleter{} agent, workspace := newTestAgent(t, completer, func(config *Config) { - config.SeesImages = func(model string) ModelSight { return SightOf(model == "vendor/slot-eyes") } + config.SeesImages = func(model string) (bool, bool) { return seesIf(model == "vendor/slot-eyes") } withSlot(map[string]string{"vision": "vendor/slot-eyes"})(config) }) @@ -114,7 +114,7 @@ func TestTheVisionFallbackRidesTheSlot(t *testing.T) { func TestASlotNamingTheBlindModelIsRefused(t *testing.T) { completer := &scriptedCompleter{} agent, workspace := newTestAgent(t, completer, func(config *Config) { - config.SeesImages = func(string) ModelSight { return SightOf(false) } + config.SeesImages = func(string) (bool, bool) { return seesIf(false) } withSlot(map[string]string{"vision": "test/model"})(config) }) diff --git a/internal/tui3/imagepaste_test.go b/internal/tui3/imagepaste_test.go index ada5ad3866..60307b0bdc 100644 --- a/internal/tui3/imagepaste_test.go +++ b/internal/tui3/imagepaste_test.go @@ -454,7 +454,7 @@ func TestADroppedPicturesTokenAndBytesBothReachTheWire(t *testing.T) { APIKey: "test", BaseURL: server.URL, System: "SYSTEM", - SeesImages: func(string) session.ModelSight { return session.SightOf(true) }, + SeesImages: func(string) (bool, bool) { return seesIf(true) }, }) if err != nil { t.Fatalf("session.New: %v", err) @@ -523,3 +523,8 @@ func TestADroppedPicturesTokenAndBytesBothReachTheWire(t *testing.T) { t.Fatalf("the picture reached the model as %q", parts[1].ImageURL.URL) } } + +// seesIf is a fixture's yes-or-no as the oracle's pair ([session.Config.SeesImages]): a +// test that says a model sees or does not is a test that KNOWS, and the third +// answer — nobody has said — is written out where it is the subject. +func seesIf(sees bool) (bool, bool) { return sees, true }