diff --git a/cmd/aforge/chatv3.go b/cmd/aforge/chatv3.go index 0bf9c4cabc..a5403feee3 100644 --- a/cmd/aforge/chatv3.go +++ b/cmd/aforge/chatv3.go @@ -897,7 +897,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 @@ -2127,13 +2127,28 @@ 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 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) bool { + return func(model string) (sees, known bool) { model = strings.TrimSpace(model) if model == "" { - return false + return false, false } if models != nil { if rows := models.ModelsNow(); len(rows) > 0 { @@ -2143,9 +2158,11 @@ func v3SeesImages(models v3Catalog) func(string) bool { } } // 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 shown + // like any unknown's. + return false, false } } once.Do(func() { cached = tui3.CachedModels() }) @@ -2154,7 +2171,7 @@ func v3SeesImages(models v3Catalog) func(string) bool { return v3ReadsImages(row.Input) } } - return false + return false, false } } @@ -2171,13 +2188,16 @@ func v3SeesImages(models v3Catalog) func(string) bool { // 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/cmd/aforge/chatv3_images_test.go b/cmd/aforge/chatv3_images_test.go index 7c3285c4ab..29ed507351 100644 --- a/cmd/aforge/chatv3_images_test.go +++ b/cmd/aforge/chatv3_images_test.go @@ -21,33 +21,47 @@ 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", 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 := 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 !cfg.SupportsImages("Vendor/Sees") { - t.Fatal("the gate answered case rather than identity") + if got := sightWord(cfg.SeesImages("Vendor/Sees")); got != "sees" { + 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 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) + } + // 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) } } - // 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 := 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 v3SeesImages(nil)("vendor/sees") { - t.Fatal("no catalog at all vouched for a model") + if got := sightWord(v3SeesImages(nil)("vendor/sees")); got != "nobody has said" { + t.Fatalf("no catalog at all answered %v for a model", got) } } @@ -69,14 +83,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 := 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", "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 := 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 := sightWord(gate("vendor/unheard-of")); got != "nobody has said" { + 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,18 +102,38 @@ 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 := 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) } - if v3ReadsImages(nil) || v3ReadsImages([]string{"text", "audio"}) { - t.Fatal("a row with no image input was read as one that has it") + // 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) + } +} + +// 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 eeeb016cfe..30b8688e6a 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 := 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() 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..9174471f53 --- /dev/null +++ b/docs/changes/unreleased/993-chrome-names-the-wire-model.md @@ -0,0 +1,27 @@ +--- +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()` — `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 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 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." + - "`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." + - "`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 — 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 +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 63605358bb..ce04769cc6 100644 --- a/internal/manual/chat/commands.md +++ b/internal/manual/chat/commands.md @@ -894,8 +894,9 @@ on the machine you are already pinned to, where the same key takes the pin off a 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 c485d3a4ea..587b57b1e4 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 48c3a9d417..9cea354a72 100644 --- a/internal/manual/chat/models-and-cost.md +++ b/internal/manual/chat/models-and-cost.md @@ -72,7 +72,11 @@ 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 — 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 @@ -963,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` — @@ -990,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 @@ -1016,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 24b6f6b425..f264deb73c 100644 --- a/internal/manual/chat/running-on-another-machine.md +++ b/internal/manual/chat/running-on-another-machine.md @@ -381,10 +381,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 6dde6bd68b..3edb8717bb 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,70 @@ 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. + +**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 — zhipu/glm-5.3-flash is answering now, and the next request takes it +``` + +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 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 +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..e0dad25117 100644 --- a/internal/remote/effort.go +++ b/internal/remote/effort.go @@ -53,6 +53,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..e31fc33648 100644 --- a/internal/remote/server.go +++ b/internal/remote/server.go @@ -1143,7 +1143,10 @@ 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. 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..e27f146fd3 --- /dev/null +++ b/internal/remote/turnmodel.go @@ -0,0 +1,28 @@ +package remote + +// ── THE MODEL THE TURN IN FLIGHT IS ON, ACROSS THE WIRE ───────────────────── +// +// 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 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. + +// 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/session/agent.go b/internal/session/agent.go index 462981ee9a..7be0534ef2 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 @@ -606,13 +602,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,20 +657,17 @@ 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 // (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. -// -// 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. 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 +715,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 +1619,19 @@ 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 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 3f4977a9f2..05e0f9d916 100644 --- a/internal/session/blindswap.go +++ b/internal/session/blindswap.go @@ -1,72 +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. // -// 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. -func (a *Agent) scrubBlindImagePartsLocked(model string) { - if a.config.SupportsImages != nil && a.config.SupportsImages(model) { - 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 @@ -79,3 +109,26 @@ func hasImagePart(message ai.Message) bool { } return false } + +// ── WHAT THIS BUILD KNOWS ABOUT A MODEL'S EYES ────────────────────────────── + +// seesImages is what this session knows about one model: whether it can read a +// picture, and whether anybody has said either way. +// +// 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 false, false + } + return a.config.SeesImages(model) +} diff --git a/internal/session/blindswap_test.go b/internal/session/blindswap_test.go index 98052e9c4d..8f42d599d9 100644 --- a/internal/session/blindswap_test.go +++ b/internal/session/blindswap_test.go @@ -41,13 +41,22 @@ 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. -func TestSetModelScrubsImagePartsForAModelThatCannotSee(t *testing.T) { - completer := &scriptedCompleter{} +// 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 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.SupportsImages = func(model string) bool { return 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") @@ -55,17 +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 != 0 { - t.Fatalf("%d image parts survived the swap onto a blind model", count) - } - text := transcriptText(agent) + collect(t, mustSubmit(t, agent, "and now?")) + 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) @@ -73,12 +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") - if again := transcriptText(agent); again != text { - t.Fatalf("a second swap rewrote the transcript:\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) } } @@ -132,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) { @@ -149,32 +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, - SupportsImages: func(model string) bool { return model == "test/model" }, - }, &scriptedCompleter{}) + Workspace: workspace, + Model: "vendor/blind", + System: "SYSTEM", + SessionFile: journal, + 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) { - agent, _ := newTestAgent(t, &scriptedCompleter{}, func(config *Config) { - config.SupportsImages = func(model string) bool { return model == "test/model" } +// 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, 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) (bool, bool) { return seesIf(model == "test/model") } }) agent.mu.Lock() agent.messages = append(agent.messages, ai.Message{Role: "user", Content: []ai.ContentPart{ @@ -184,10 +227,21 @@ func TestTheScrubDropsBytesEvenWithNoJournaledPath(t *testing.T) { agent.mu.Unlock() agent.SetModel("vendor/blind") - if count := countImageParts(agent); count != 0 { - t.Fatalf("%d image parts survived", count) + collect(t, mustSubmit(t, agent, "and now?")) + 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/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 c5deceb40a..394e41858e 100644 --- a/internal/session/image.go +++ b/internal/session/image.go @@ -105,12 +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() - if a.config.SupportsImages == nil || !a.config.SupportsImages(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 + // ([Config.SeesImages]). + if sees, known := a.seesImages(model); !sees || !known { return a.visionTurn(ctx, text, images, model) } @@ -402,6 +412,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.riding = seer hub := newEventHub() a.hub = hub turnCtx, cancel := context.WithCancelCause(ctx) @@ -512,13 +528,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 } @@ -531,7 +547,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..18124cf4ee 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) (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.SupportsImages = func(string) bool { return 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.SupportsImages = func(model string) bool { return model == "vendor/sees" } + config.SeesImages = func(model string) (bool, bool) { return seesIf(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) (bool, bool) { return seesIf(true) }, }) if err != nil { t.Fatalf("New: %v", err) diff --git a/internal/session/loop.go b/internal/session/loop.go index 9ac482d9af..79b69c0b37 100644 --- a/internal/session/loop.go +++ b/internal/session/loop.go @@ -710,6 +710,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 @@ -906,6 +914,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 { @@ -2037,6 +2046,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 4ac832ebb6..532d573d65 100644 --- a/internal/session/orchestrate.go +++ b/internal/session/orchestrate.go @@ -1094,7 +1094,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/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/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 1ac9f97700..4822369a20 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -1341,19 +1341,29 @@ 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: - // /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 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: + // 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 @@ -2683,6 +2693,22 @@ 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, 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 6c429609d2..ca8fa64ffe 100644 --- a/internal/session/sessionfile.go +++ b/internal/session/sessionfile.go @@ -1280,30 +1280,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 2a52afb149..57a16b5be3 100644 --- a/internal/session/steer.go +++ b/internal/session/steer.go @@ -509,6 +509,15 @@ func (a *Agent) rideModel(model string) { 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 // session's own model when there is not — a pick made between two turns is news // exactly when it differs from the model the next turn would latch. diff --git a/internal/session/task_run.go b/internal/session/task_run.go index d828bfadd2..ace37519b4 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 92208bff19..5a43e84111 100644 --- a/internal/session/tools_doc.go +++ b/internal/session/tools_doc.go @@ -404,11 +404,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.config.SupportsImages != nil && a.config.SupportsImages(model) { + if sees, known := a.seesImages(model); sees && known { 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..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.SupportsImages = func(model string) bool { return 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.SupportsImages = func(string) bool { return 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.SupportsImages = func(string) bool { return 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 7df54fa541..4228ce1c22 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) (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.SupportsImages = func(string) bool { return false } + config.SeesImages = func(string) (bool, bool) { return seesIf(false) } }}, {"settings with nothing for vision", func(config *Config) { - config.SupportsImages = func(string) bool { return 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.SupportsImages = func(string) bool { return 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_law_test.go b/internal/session/turnmodel_law_test.go new file mode 100644 index 0000000000..40f856916a --- /dev/null +++ b/internal/session/turnmodel_law_test.go @@ -0,0 +1,165 @@ +package session + +// 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.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 is assigned by [Agent.ridesOnLocked] and by nothing else. The three +// places a move happens reach it through that one function: +// +// - [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. +// +// 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" + "go/token" + "testing" +) + +// 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, + "latchTheModel": true, + "rideModel": 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 != "riding" { + continue + } + written[fn.Name.Name] = true + if !turnModelWriters[fn.Name.Name] { + 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())) + } + } + return true + }) + } + }) + 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) + } + } +} + +// 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..cc45328509 --- /dev/null +++ b/internal/session/turnmodel_test.go @@ -0,0 +1,303 @@ +package session + +// THE LAW: THE WORK IS ON ONE MODEL AT A TIME, IT SAYS WHICH, AND WHAT IS DRAWN +// BESIDE IT IS THAT MODEL. +// +// 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" + "testing" + "time" + + "github.com/Agent-Field/aforge-v2/internal/provider" + "github.com/Agent-Field/agentfield/sdk/go/ai" +) + +// 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(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("and the step after is on the new model"), nil + }, + }} + agent, workspace := newTestAgent(t, completer, func(config *Config) { + config.SeesImages = func(model string) (bool, bool) { return seesIf(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 <-spoke: + case <-time.After(10 * time.Second): + t.Fatal("the first request never streamed a word") + } + if got := agent.TurnModel(); got != "test/model" { + t.Fatalf("the work in flight says it is on %q, want test/model", got) + } + + agent.SetModel("vendor/blind") + + // 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 work moved to %q while an answer was arriving on test/model", got) + } + // 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 arriving answer is left with %d pictures, want the 1 it started with", got) + } + + close(release) + collect(t, events) + + // 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 == "" || news.Model == "vendor/blind" { + continue + } + said = true + if news.Model != "test/model" { + 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) (bool, bool) { return seesIf(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") + } + + 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) + } + // 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 := countImageParts(agent); got != 0 { + t.Fatalf("%d pictures are still in the transcript the blind model is being sent", 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) + } +} + +// 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) (bool, bool) { return sees, true } + 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") + } + 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, 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("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 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) (bool, bool) { + switch model { + case "test/model": + return true, true + case "vendor/blind": + return false, true + } + return false, false + } + 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 := 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 := picturesIn(t, sent); got != 1 { + t.Fatalf("a model nobody has vouched for was sent %d pictures, want the one that is there", got) + } + + agent.SetModel("vendor/blind") + collect(t, mustSubmit(t, agent, "and now?")) + 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 f3274de50f..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.SupportsImages = func(model string) bool { return 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.SupportsImages = func(string) bool { return false } + config.SeesImages = func(string) (bool, bool) { return seesIf(false) } withSlot(map[string]string{"vision": "test/model"})(config) }) diff --git a/internal/tui3/app.go b/internal/tui3/app.go index 3b3eefde20..4227388b84 100644 --- a/internal/tui3/app.go +++ b/internal/tui3/app.go @@ -996,6 +996,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. @@ -6795,11 +6806,19 @@ 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. + // 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.pinLane(a.model, value) + a.readWire() + a.pinLane(a.wireModel(), value) return nil case modelAutoLane: - a.clearLanePin(a.model) + a.readWire() + 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..60b95bc989 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 @@ -173,9 +174,18 @@ 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 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. ConversationEffort() string @@ -225,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 @@ -233,15 +243,20 @@ 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 { - dial, ok := a.effortDial() - if !ok { + if _, ok := a.effortDial(); !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 := a.effortWord() if word == "" { word = effortAutoWord } @@ -249,7 +264,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 @@ -334,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, @@ -353,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/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..60307b0bdc 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) (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 } diff --git a/internal/tui3/lanes.go b/internal/tui3/lanes.go index 746c45589a..b40207b66a 100644 --- a/internal/tui3/lanes.go +++ b/internal/tui3/lanes.go @@ -1332,10 +1332,128 @@ func (a *app) pinnedNow() string { // cannot see it), routing `off` (no lane is sent), no model, and a model served // by a connected service direct rather than through the router. func (a *app) laneForceNow() laneForce { - if a.hosted() || a.routingOff() || a.model == "" || a.modelIsDirect(a.model) { + model := a.wireModel() + if a.hosted() || a.routingOff() || model == "" || a.modelIsDirect(model) { return laneForce{} } - return laneInForce(a.model) + return laneInForce(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 — 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 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 +// (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 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. +// +// 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 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 +// reading, and it is the same answer the composer's own hints are drawn from. +// +// 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. +// +// 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 "" + } + 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 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 }) + return door, ok } // modelWord is THE MODEL AS THE CHROME NAMES IT: its basename ([modelBase]), @@ -1344,9 +1462,11 @@ func (a *app) laneForceNow() laneForce { // 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. // @@ -1365,9 +1485,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 == "" { @@ -1395,7 +1521,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() } } @@ -1456,7 +1585,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 58b00e1c7f..e88cab5920 100644 --- a/internal/tui3/palette.go +++ b/internal/tui3/palette.go @@ -1926,11 +1926,18 @@ 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. + 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 // 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() } @@ -2100,6 +2107,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 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. + // + // 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 == "" { @@ -2132,11 +2152,39 @@ 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 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 += " — " + turnOn + " is answering now, and " + roomModelNextWord + } 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 a93c6779e4..67e5bcf085 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 @@ -1557,8 +1560,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 waiting != "": + word = waitForWord + waiting } if clock := countUpWord(waited); clock != "" { word += " · " + clock @@ -2429,7 +2436,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 +2449,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 +2515,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/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 new file mode 100644 index 0000000000..d50752a2f5 --- /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.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 +// 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 != "askTurnModel" { + 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("askTurnModel no longer asks the engine for the model it is on") + } + if readsDesk { + 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("askTurnModel 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..3592557598 --- /dev/null +++ b/internal/tui3/wiremodel_test.go @@ -0,0 +1,169 @@ +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 — " + wireLatched + " is answering now, and " + roomModelNextWord + // 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))) + } +} + +// 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, "is answering now") { + 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) + } +}