From ebc20d4d9d6801b56d060776a562658ee427dc02 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sun, 13 Sep 2026 22:15:24 -0400 Subject: [PATCH 01/11] provider, tui3, session, cmd/aforge: the chip names a machine only while the wire would demand it, a routing change lands on the next message, and a recording launch makes its own calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #1022 saw `moonshotai/kimi-k3@morph` on the status chip over three turns another machine answered. The chip, the panel's `lane` tail and the fold's mark were drawn from the profile row while the wire asked the transport, and the two disagree the moment the wire retires a pairing. They now read one door — `provider.PinNow`, which answers the machine a request will demand and, beside it, the machine a person's row still names that nothing is asking for — and the model is folded by the transport, under the same normaliser the retirement was written with, rather than against a key built on the surface's own spelling. A routing change landed on the next session rather than the next message, because the chat door read the row and wrote it onto the session config, which every client then held as an answer of its own — and an answer handed down beats the row this process installs. The door now installs the row and hands down nothing, so the conversation's clients read the live one; the settings panel re-installs it and re-reads its own two fields in the same keystroke. An engine host and a task child still carry their parent's row explicitly and still win. `AFORGE_DEBUG=1 aforge` wrote a folder holding run.json and no request bodies, because the host rule read the --debug FLAG while the record has three doors. It reads the switch, so the environment pin keeps the calls in this process exactly as the flag does. And the fold got the two gestures it was missing: `enter` on the machine already in force takes the pin off, and typing while a fold is open filters that model's machines before it filters the models. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_0182eyxXP8Xe7ADNKpYDe4wv --- cmd/aforge/chatv3.go | 38 +-- cmd/aforge/chatv3_local.go | 14 +- cmd/aforge/recordroad_test.go | 86 +++++++ cmd/aforge/routinglive_test.go | 59 +++++ internal/config/settings.go | 11 +- internal/manual/chat/commands.md | 3 +- internal/manual/chat/debug-record.md | 7 + internal/manual/chat/keys.md | 6 +- internal/manual/chat/lanes.md | 27 ++- internal/manual/chat/models-and-cost.md | 9 +- .../manual/chat/staying-on-that-machine.md | 6 +- internal/provider/lanepin.go | 46 +++- internal/provider/pinchip_test.go | 152 ++++++++++++ internal/provider/velocity.go | 16 ++ internal/session/agent.go | 2 +- internal/session/clientdoor.go | 19 ++ internal/session/routinglive_test.go | 42 ++++ internal/session/session.go | 8 + internal/tui3/app.go | 13 +- internal/tui3/lanes.go | 127 +++++++++- internal/tui3/palette.go | 136 +++++++++-- internal/tui3/pinchip_test.go | 221 ++++++++++++++++++ internal/tui3/rowfit_test.go | 5 +- internal/tui3/settings.go | 70 ++++-- 24 files changed, 1041 insertions(+), 82 deletions(-) create mode 100644 cmd/aforge/recordroad_test.go create mode 100644 cmd/aforge/routinglive_test.go create mode 100644 internal/provider/pinchip_test.go create mode 100644 internal/session/routinglive_test.go create mode 100644 internal/tui3/pinchip_test.go diff --git a/cmd/aforge/chatv3.go b/cmd/aforge/chatv3.go index 13b6cdbf25..05fb4908a0 100644 --- a/cmd/aforge/chatv3.go +++ b/cmd/aforge/chatv3.go @@ -22,7 +22,6 @@ import ( "github.com/Agent-Field/aforge-v2/internal/home" "github.com/Agent-Field/aforge-v2/internal/leave" "github.com/Agent-Field/aforge-v2/internal/openrouterauth" - "github.com/Agent-Field/aforge-v2/internal/provider" "github.com/Agent-Field/aforge-v2/internal/roles" "github.com/Agent-Field/aforge-v2/internal/search" "github.com/Agent-Field/aforge-v2/internal/session" @@ -239,8 +238,13 @@ func openChatV3(name string, args []string, pickSession bool) error { if workspace, take := v3HostRoad(v3HostChoice{ noHost: *noHost, once: strings.TrimSpace(*once) != "", - debug: *debug, - setup: !v3MachineIsSetUp(), + // THE SWITCH AND NOT THE FLAG. --debug has already turned the record on + // above, and so has AFORGE_DEBUG in the shell that started this process + // (internal/trace's init) — both are this process being told to record, + // and both must keep the calls in this process to have anything to + // record ([v3HostChoice.debug]). + debug: trace.Enabled(), + setup: !v3MachineIsSetUp(), }); take { err := openChatV3Local(localLaunch{ workspace: workspace, @@ -1434,17 +1438,23 @@ func applyV3Governance(cfg session.Config, profileDir string, yolo, oneModel boo // process, built at the door and assigned by the launch // (chatv3_process.go's [v3Process.Conns]), because two managers on one store // are two caches with no way to tell each other that a token has moved. - // How this session chooses among the endpoints serving its model — and it is - // read as the CHOICE rather than as the resolved default, so an unwritten row - // arrives here empty. The adapter needs to be able to tell "nobody said" from - // "somebody said latency": with nothing said it routes a person's own turn by - // speed and a task node or an errand by price, and with a word written that - // word wins outright (internal/provider's velocity.go). The parse is still - // total, so a word this build does not know leaves the row unset rather than - // taking routing away. - if word := config.RoutingChoiceAt(profileDir); word != "" { - cfg.Routing, _ = provider.ParseRoutingStrategy(word) - } + // HOW THIS SESSION CHOOSES AMONG THE ENDPOINTS SERVING ITS MODEL IS NOT SET + // HERE, and that absence is the point. This door used to read the routing row + // and write it onto the config, which the session then handed to every client + // it built as an answer of its own ([provider.StaticRouting]) — and an answer + // handed down WINS over the row this process installs, for the good reason + // that an engine host and a task child carry their parent's row explicitly. + // So the conversation's clients were pinned to the word that was on disk at + // launch: a person who cycled `routing` in the settings panel watched the row + // change, watched the `lane` row go on explaining itself in the old word, and + // got the old routing on every request until they relaunched (issue #1022). + // + // The row is installed process-wide one line below, where every client that + // was handed nothing reads it — including this session's, now — and the panel + // re-installs it the moment it is written (internal/config's + // InstallRoutingRow). A caller that really does have a row of its own still + // hands one down and still wins; this door does not, because the row it would + // hand down is the very row it is installing. // AND WHICH MACHINE BEHIND THAT MODEL, which is the row beside routing and a // different question: routing says what a request PREFERS, and this says // which endpoint it actually goes to (internal/config's lane keys). The diff --git a/cmd/aforge/chatv3_local.go b/cmd/aforge/chatv3_local.go index 110e894a7c..ee72b184ff 100644 --- a/cmd/aforge/chatv3_local.go +++ b/cmd/aforge/chatv3_local.go @@ -512,9 +512,17 @@ type v3HostChoice struct { // surprise — but it joins one that is already there, so a scripted message // lands in the conversation a person is actually in. once bool - // debug is --debug: the model-call record is written by the process that - // makes the calls, and over a socket that process is the host, which was - // never told to record. So the flag keeps the launch here, where it is real. + // debug is THE RECORD'S SWITCH and not the flag alone ([trace.Enabled]): the + // model-call record is written by the process that makes the calls, and over + // a socket that process is the host, which was never told to record. So a + // run that is recording keeps its launch here, where the calls are real. + // + // IT IS THE SWITCH BECAUSE THE SWITCH HAS THREE DOORS AND THE FLAG IS ONE. + // Reading the flag alone meant `AFORGE_DEBUG=1 aforge` took the host road, + // and four launches wrote a folder holding run.json and not one request body + // — the header written by this process, the calls made by another — while + // `aforge chat --debug` wrote them all (issue #1022). The manual advertises + // the environment pin for exactly this, so it reaches the same door. debug bool // setup says this machine may still have to be set up — no key it can find — // and setting one up is a conversation with the person at this terminal diff --git a/cmd/aforge/recordroad_test.go b/cmd/aforge/recordroad_test.go new file mode 100644 index 0000000000..79d4f45737 --- /dev/null +++ b/cmd/aforge/recordroad_test.go @@ -0,0 +1,86 @@ +package main + +import ( + "go/ast" + "go/parser" + "go/printer" + "go/token" + "strings" + "testing" +) + +// ── A RUN THAT IS RECORDING MAKES ITS OWN CALLS ───────────────────────────── +// +// THE MEASURED FAILURE (issue #1022). `AFORGE_DEBUG=1 aforge` wrote a folder +// holding `run.json` and not one request body, four launches running: the header +// is written by the process at the door and the calls were made by this +// workspace's session host, which was never told to record. `aforge chat +// --debug` wrote them all, because the host rule read the FLAG while the record +// has three doors and the flag is one of them. +// +// The rule below is the switch and not the flag, and this is the law that keeps +// it so: a shape test, because there is no way to reach the wiring through the +// door without opening a terminal, and because a flag re-read here is exactly +// how it was wrong. + +// TestTheHostRoadIsDecidedByTheRecordsSwitchAndNotOneOfItsDoors reads the chat +// door's own source: the `debug` field of the host choice it builds must be the +// switch ([trace.Enabled]), never the flag variable it parsed a moment earlier. +func TestTheHostRoadIsDecidedByTheRecordsSwitchAndNotOneOfItsDoors(t *testing.T) { + fset := token.NewFileSet() + parsed, err := parser.ParseFile(fset, "chatv3.go", nil, 0) + if err != nil { + t.Fatalf("read chatv3.go: %v", err) + } + + found := false + ast.Inspect(parsed, func(node ast.Node) bool { + literal, ok := node.(*ast.CompositeLit) + if !ok { + return true + } + if name, _ := literal.Type.(*ast.Ident); name == nil || name.Name != "v3HostChoice" { + return true + } + for _, element := range literal.Elts { + pair, ok := element.(*ast.KeyValueExpr) + if !ok { + continue + } + if key, _ := pair.Key.(*ast.Ident); key == nil || key.Name != "debug" { + continue + } + found = true + var written strings.Builder + if err := printer.Fprint(&written, fset, pair.Value); err != nil { + t.Fatalf("print the debug field: %v", err) + } + if got := written.String(); got != "trace.Enabled()" { + t.Fatalf( + "the chat door decides the host road from %s at %s; it must read the record's own switch, "+ + "trace.Enabled(), so the environment pin keeps the calls in this process exactly as --debug does", + got, fset.Position(pair.Value.Pos()), + ) + } + } + return true + }) + if !found { + t.Fatal("chatv3.go builds no v3HostChoice with a debug field; this law has stopped watching the chat door") + } +} + +// AND THE RULE ITSELF REFUSES THE HOST ROAD WHILE THE RECORD IS ON, whichever +// door turned it on — the other half of the same promise, asserted on the rule +// rather than on the source. +func TestARecordingLaunchKeepsItsCallsInThisProcess(t *testing.T) { + shortStateRoot(t) + workspace := t.TempDir() + answeringHost(t, workspace) + if !v3TakeHostRoad(workspace, v3HostChoice{}) { + t.Fatal("a host was answering and an ordinary launch did not take it, so this test proves nothing") + } + if v3TakeHostRoad(workspace, v3HostChoice{debug: true}) { + t.Fatal("a recording launch handed its calls to a host that was never told to record") + } +} diff --git a/cmd/aforge/routinglive_test.go b/cmd/aforge/routinglive_test.go new file mode 100644 index 0000000000..84bb85ad3b --- /dev/null +++ b/cmd/aforge/routinglive_test.go @@ -0,0 +1,59 @@ +package main + +import ( + "testing" + + "github.com/Agent-Field/aforge-v2/internal/config" + "github.com/Agent-Field/aforge-v2/internal/provider" + "github.com/Agent-Field/aforge-v2/internal/session" +) + +// ── THE ROUTING ROW IS INSTALLED, NEVER HANDED DOWN ───────────────────────── +// +// THE MEASURED FAILURE (issue #1022). This door read the routing row and wrote +// it onto the session's config, which the session handed to every client it +// built as an answer OF ITS OWN — and an answer handed down beats the row this +// process installs, because an engine host and a task child carry their parent's +// row that way on purpose. So the conversation's clients were pinned to the word +// that was on disk at launch: somebody cycled `routing` in the settings panel, +// watched the row change, and went on being routed by the old word until they +// relaunched. +// +// The fix is an absence, and an absence is the one thing a reader cannot see, so +// it is asserted here: the door installs the row and hands down nothing. +func TestTheProfileDoorInstallsTheRoutingRowAndHandsDownNone(t *testing.T) { + before := provider.RoutingNow() + t.Cleanup(func() { provider.InstallRouting(before) }) + t.Setenv("AFORGE_HOME", t.TempDir()) + dir := t.TempDir() + row, ok := config.NewSettings(config.SettingsOptions{ProfileDir: dir}).Row(config.KeyRouting) + if !ok || row.Apply(config.RoutingLatency) != nil { + t.Fatal("could not write the routing row") + } + + built, err := applyV3Governance(session.Config{Workspace: t.TempDir()}, dir, false, false) + if err != nil { + t.Fatalf("the door refused a plain profile: %v", err) + } + if built.Routing != "" { + t.Fatalf("the door handed the session its own routing answer (%q), which no later write can overrule", built.Routing) + } + if got := provider.RoutingNow(); got != provider.RoutingLatency { + t.Fatalf("the door installed routing %q, want the row the person wrote", got) + } + + // AND THE ROW MOVES UNDER THE SESSION THAT WAS BUILT, which is the whole + // point of installing it: the panel re-installs on the write + // ([config.InstallRoutingRow]) and the next request is answered by the new + // word with nothing relaunched. + if row.Apply(config.RoutingSimple) != nil { + t.Fatal("could not cycle the routing row") + } + config.InstallRoutingRow(dir) + if got := provider.RoutingNow(); got != provider.RoutingSimple { + t.Fatalf("after the write the transport routes by %q, want simple", got) + } + if built.Routing != "" { + t.Fatal("the session built a moment ago acquired a routing row of its own") + } +} diff --git a/internal/config/settings.go b/internal/config/settings.go index 9f0264be10..c11e65bb1d 100644 --- a/internal/config/settings.go +++ b/internal/config/settings.go @@ -848,11 +848,20 @@ func LanePinAt(profileDir, slot string) provider.LanePin { // and every client falls to the shipped row together // ([provider.DefaultRouting]). func InstallLaneRows(profileDir string) { - provider.InstallRouting(installedRoutingFor(profileDir)) + InstallRoutingRow(profileDir) provider.SetLanePin(LanePinAt(profileDir, LaneSlotTalk)) provider.SetLaneGuard(LaneGuardAt(profileDir)) } +// InstallRoutingRow hands the routing row ALONE to the transport, and it is the +// half of [InstallLaneRows] the settings panel calls by itself: somebody cycles +// `routing`, the row is written, and the very next request has to go out under +// it. The lane rows beside it are untouched because nothing about them changed — +// re-stating a pin here would be a resolver's write nobody asked for. +func InstallRoutingRow(profileDir string) { + provider.InstallRouting(installedRoutingFor(profileDir)) +} + // installedRoutingFor is the routing row as the transport's own vocabulary, and // the EMPTY strategy when a person has written nothing readable. The parse is // total, so a word this build does not know installs nothing rather than taking diff --git a/internal/manual/chat/commands.md b/internal/manual/chat/commands.md index a732741907..63605358bb 100644 --- a/internal/manual/chat/commands.md +++ b/internal/manual/chat/commands.md @@ -889,7 +889,8 @@ filter · ↑↓ · → lanes · ctrl+t effort · ctrl+r refresh · enter · esc ``` and the hint slot above the box follows the cursor: `→ lanes · enter switch · esc` on a -model, `enter choose · ← back · esc` inside its lanes. +model, `enter choose · ← back · esc` inside its lanes — and `enter unpin · ← back · esc` +on the machine you are already pinned to, where the same key takes the pin off again. 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 — diff --git a/internal/manual/chat/debug-record.md b/internal/manual/chat/debug-record.md index 857a860d9a..0e6129c05c 100644 --- a/internal/manual/chat/debug-record.md +++ b/internal/manual/chat/debug-record.md @@ -15,6 +15,13 @@ AFORGE_DEBUG=1 aforge for one shell, one run `AFORGE_CALL_LOG_BODIES=1` — the older word, if it is the one in your shell history — means the same thing now. +**A recording launch keeps its conversation in this terminal.** The record is written by +the process that makes the calls, and ordinarily `aforge` hands your conversation to this +folder's background session host — which was never told to record, and would leave you a +folder holding `run.json` and no request bodies at all. So a run that is recording takes +the in-process road, by the pin exactly as by the flag. The conversation ends with the +terminal, the way `--no-host` does. + **It does not change what the run does.** Nothing is asked differently, nothing is slower, no model is told anything new. With it off, a run costs one check and writes nothing at all; that is why it is safe to leave the flag out and reach for it only on the day you diff --git a/internal/manual/chat/keys.md b/internal/manual/chat/keys.md index 7a06fa7a83..c485d3a4ea 100644 --- a/internal/manual/chat/keys.md +++ b/internal/manual/chat/keys.md @@ -1486,8 +1486,10 @@ it instead of switching model. Its placeholder reads exactly `filter · ↑↓ · → lanes · ctrl+t effort · ctrl+r refresh · enter · esc`; on a narrow terminal the keys drop from the right, `esc` first. The hint slot follows the cursor: `→ lanes · enter switch · esc` on a model, `enter choose · ← back · esc` inside its -lanes — with `tab lanes` and `tab back` in place of the arrows while there is typed text the -arrow would step over. +lanes, and `enter unpin · ← back · esc` on the machine already pinned, where `enter` takes +the pin off — with `tab lanes` and `tab back` in place of the arrows while there is typed +text the arrow would step over. Typing while a fold is open filters that model's machines; +only a query none of them match falls through to filtering the model list. **Sessions roster** — opened by `/resume`: the same key map, except `enter` opens the selected session. Its placeholder reads `filter · ↑↓ · enter open · esc cancel`. diff --git a/internal/manual/chat/lanes.md b/internal/manual/chat/lanes.md index f1e93d3303..9d937e3743 100644 --- a/internal/manual/chat/lanes.md +++ b/internal/manual/chat/lanes.md @@ -230,6 +230,27 @@ The hint slot says which: `→ lanes · enter switch · esc` on a model, `enter choose · ← back · esc` inside. On the default service, the `openrouter` row means "no opinion from me — let the router balance it". +## Going back to auto — unpinning with the same key that pinned, and filtering inside an open fold + +**`enter` on the machine you are already pinned to takes the pin off.** It is a toggle on +the one key that put it there, and the hint slot says so while the cursor is on that row: +`enter unpin · ← back · esc`. The row goes back to `auto`, the `@machine` comes off the +model's name, and the next request carries no machine at all. The `auto` row at the top of +the fold still does the same thing and is still the explicit way to say it — the toggle +exists because reaching that row meant walking `↑` past every machine in the list, and one +press too far lands on another model's row, where `enter` switches the model instead. + +One case is deliberately not a toggle: after the machine you pinned has refused the model +(below), nothing is asking for it any more, so `enter` there **pins it again** rather than +unpinning — which is the "pinning again puts it straight back" the refusal promises. + +**Typing in the box while a fold is open filters the machines, not the models.** With +`morph`'s fold open, typing `mor` narrows it to the machines whose names carry those +letters and leaves the fold standing. The matching is the same as for a model id — every +word you type has to match, prefix first — and a query that matches none of that model's +machines falls through to filtering the model list as it always has, closing the fold with +it. + **A model nobody has measured still opens**, onto `auto` and `openrouter`, with one line where the machines would be: `no machine has been measured for this model yet — machines show up after its first answer`. @@ -273,7 +294,11 @@ What that means, exactly: - **the line stays in the conversation.** It is not one of the dim retry notes the work chip collapses when an answer lands, so it is still on the screen after the turn finishes; -- **your settings row is not touched.** It still reads `pinned: coreweave`; +- **your settings row is not touched.** The `lane` row on the Providers tab still reads + `pinned: coreweave`, exactly as you wrote it. What changes is everything that names the + machine **requests are going to**: the `@coreweave` comes off the model's name, the tail + on the `your model` row reads `auto (coreweave cannot serve this model)`, and the fold's + mark moves to `auto`; - **every other model still goes to that machine.** The refusal was about one pairing; - **pinning again puts it straight back**, on the very next request — and that diff --git a/internal/manual/chat/models-and-cost.md b/internal/manual/chat/models-and-cost.md index 04d8779f8b..48c3a9d417 100644 --- a/internal/manual/chat/models-and-cost.md +++ b/internal/manual/chat/models-and-cost.md @@ -2614,7 +2614,9 @@ Setting **routing** yourself is how you turn it on, and it applies everywhere: - **`simple`** is the shipped row described above. - **`off`** sends no preference and stops timing endpoints altogether. -A change lands on the next session. +A change here takes effect on your **next message** — the row goes straight to the layer +that sends requests, so nothing waits for a relaunch. The `lane` row under it re-reads what +`auto` means in the new word on the same frame. Under `latency` or `price`, where a model publishes no price, no cap is sent at all rather than one guessed from something else. If the router refuses a request, aforge first widens the endpoint set while keeping the cap. Only if that wider request is refused too does aforge lift the cap rather than fail the turn. Each change has its own attempt line. @@ -2673,7 +2675,10 @@ your model is part of the same decision as the model: routing simple ``` -The tail on the model row is the machine: `pinned: cloudflare` once you have pinned one, +The tail on the model row is the machine **requests are actually going to**: +`pinned: cloudflare` once you have pinned one and the wire is still carrying it, +`auto (cloudflare cannot serve this model)` once that machine has refused the pairing — +your `lane` row is untouched, but nothing is asking for it any more — `openrouter` when you have asked for no endpoint at all, and `auto (cloudflare now)` — a prediction of where the next turn would land — only under `latency` or `price`, where aforge is the one choosing. Under the shipped `simple` row nobody here is predicting, so diff --git a/internal/manual/chat/staying-on-that-machine.md b/internal/manual/chat/staying-on-that-machine.md index 532c52904f..9bb5711ecd 100644 --- a/internal/manual/chat/staying-on-that-machine.md +++ b/internal/manual/chat/staying-on-that-machine.md @@ -206,7 +206,11 @@ lives in this process: launch takes the host road. - **`--once`**, which never starts a host — a resident process left behind by a headless command is a surprise — though it joins one that is already there. -- **`--debug`**, because the model-call record is written by the process making the calls. +- **a run that is recording** — `--debug`, or `AFORGE_DEBUG=1` in the shell that started + it — because the model-call record is written by the process making the calls, and over + a socket that process is the host, which was never told to record. Either way of asking + keeps the conversation here, so the folder holds the request bodies and not just a + header. - **`--no-host`**, the escape hatch, for the day the host is the thing that is wrong. **The per-launch postures travel with the launch.** `--yolo`, `--no-compact`, `--one-model`, diff --git a/internal/provider/lanepin.go b/internal/provider/lanepin.go index 24efc1feb7..854a7c3239 100644 --- a/internal/provider/lanepin.go +++ b/internal/provider/lanepin.go @@ -176,14 +176,42 @@ func PersonAtTheDoor() bool { return personAtTheDoor.Load() } func readByAPerson(role lanes.Role) bool { return role.Visible() || PersonAtTheDoor() } func PinnedFor(model string) string { + demanded, _ := PinNow(model) + return demanded +} + +// PinNow is that answer and the other half of it, read together: the machine a +// request for model will demand, and the machine a person's row still names +// after the wire has stopped asking for it ([retirePinnedLane]). +// +// TOGETHER FOR [lanePinFor]'s REASON ONE LAYER UP. The two are one fact about +// one moment — is the row on the wire, and if it is not, whose name is still on +// the screen — and a surface that asked them as two questions could draw a +// machine's name beside a sentence saying nothing is asking for that machine, +// or draw neither. +// +// AND IT IS THE ONE DOOR EVERY SURFACE THAT NAMES THE PIN COMES THROUGH +// (internal/tui3's laneInForce). The chip, the model row's tail and the fold's +// mark were drawn from the SETTINGS ROW while the wire asked this file, so a +// pairing the wire retired mid-session left `@morph` on the model word over +// three turns another machine answered (issue #1022). The model is folded here, +// by [lanePinFor], under the same normaliser the retirement was written with — a +// surface folding a spelling of its own would rebuild that disagreement one +// layer down. +// +// A BASE THAT WILL NOT CARRY A LANE CHOICE AT ALL ANSWERS NEITHER, and that is +// deliberate: no request demands the machine, so nothing may name it, and what +// a person reads about that is the base's own sentence +// ([UncarriedPinLine]) rather than this one. +func PinNow(model string) (demanded, standDown string) { if CurrentLanePin().pinned() == "" || !BaseTakesLaneChoice() { - return "" + return "", "" } pin, retired := lanePinFor(model) if retired { - return "" + return "", pin.pinned() } - return pin.pinned() + return pin.pinned(), "" } // SetLaneGuard turns the speed guard on or off, and it is the ONE switch: it @@ -396,6 +424,18 @@ const RescueRetired = "retired" // sentence that gets reworded in one. func RetiredPinLine(lane string) string { return retiredPinLine(lane) } +// RetiredPinTail is the same fact in the room a SETTINGS ROW has for it: +// `(morph cannot serve this model)`, drawn after the word `auto` on the row +// whose machine is no longer being asked for (internal/tui3's laneWord). +// +// IT IS A THIRD GRAIN OF ONE FACT AND NOT A THIRD CLAIM, which is the same +// licence the rider and the parked line already take ([retirePinnedLane]): the +// sentence a person reads in the conversation says what happened and what +// happens next, and a row they come back to look at has one line to say why the +// machine they wrote down is not the machine answering. It is spelled here so +// that all three move together the day the wording moves. +func RetiredPinTail(lane string) string { return "(" + lane + " cannot serve this model)" } + func retiredPinLine(lane string) string { return lane + " cannot serve this model; routing on auto for this model until you pin again" } diff --git a/internal/provider/pinchip_test.go b/internal/provider/pinchip_test.go new file mode 100644 index 0000000000..01598e9cbd --- /dev/null +++ b/internal/provider/pinchip_test.go @@ -0,0 +1,152 @@ +package provider + +import ( + "strings" + "testing" + + lanes "github.com/Agent-Field/aforge-v2/internal/lane" + "github.com/Agent-Field/agentfield/sdk/go/ai" +) + +// ── THE CHIP AND THE WIRE READ ONE FACT ───────────────────────────────────── +// +// THE MEASURED FAILURE (issue #1022, dev 649392ad7). Morph was pinned from the +// settings sheet; the `lane` row read `pinned: Morph`, the status chip read +// `moonshotai/kimi-k3@morph`, and three turns were answered by Sail Research. A +// body carrying `{"only":["Morph"],"allow_fallbacks":false}` cannot be answered +// by another machine, so for those three turns the pin was not on the wire while +// the chrome said it was. +// +// The surface names the machine through one door ([PinNow]) and the wire draws +// its demand through another ([Client.drawLaneChoice]). This is the law that the +// two answer the same question: for every state the row and the run can be in, +// the machine a surface may name is exactly the machine the next request a +// person is reading would demand. + +// demandedFor is what the wire would ask for on a call this person is waiting +// on — the `only` of the draw, or nothing where the draw makes no choice. +func demandedFor(t *testing.T, rig *laneRig, model string) string { + t.Helper() + request := &ai.Request{Model: model, Messages: userMessages("hello")} + choice, made := rig.client.drawLaneChoice(turnKnobs(), model, request) + if !made || len(choice.Only) == 0 { + return "" + } + if len(choice.Only) != 1 { + t.Fatalf("the draw demanded %v, and a pin is one machine", choice.Only) + } + return choice.Only[0] +} + +// THE LAW, WALKED OVER EVERY STATE OF THE ROW. Each step moves one fact — the +// row, the wire's answer to it, the base — and after each one the chip's fact +// and the wire's demand are compared. They are compared rather than each +// asserted against a literal on purpose: what this test is about is that they +// cannot disagree, and a literal in two places is how they came to. +func TestTheChipNamesAMachineOnlyWhileTheWireWouldDemandIt(t *testing.T) { + rig := newLaneRig(t, "chip/one-fact", retiredLanes()...) + forgotten(t) + installedRow(t, RoutingSimple) + rig.client.config.Routing = nil + model := rig.model + + agree := func(state string) string { + t.Helper() + named, stood := PinNow(model) + demanded := demandedFor(t, rig, model) + if !strings.EqualFold(named, demanded) { + t.Fatalf("%s: the chip would say %q and the wire demands %q", state, named, demanded) + } + if named != "" && stood != "" { + t.Fatalf("%s: the pin is on the wire as %q and stood down as %q at once", state, named, stood) + } + return stood + } + + // 1. NOBODY HAS CHOSEN. No name on the chrome, no provider object on the + // request. + pinned(t, LanePin{}) + agree("auto") + + // 2. THE ROW ASKS FOR NO LANE AT ALL. `openrouter` is a person declining to + // name a machine, so there is still no machine to name. + SetLanePin(LanePin{OpenRouter: true}) + agree("openrouter") + + // 3. A PIN. The one state where the chrome may write `@machine`. + SetLanePin(LanePin{Lane: "Ghost"}) + if named, _ := PinNow(model); !strings.EqualFold(named, "Ghost") { + t.Fatalf("a live pin reads %q, want Ghost", named) + } + agree("pinned") + + // 4. THE WIRE HAS RETIRED THE PAIRING. This is the state the defect was: the + // row still says Ghost, every request since goes out on auto, and the chip + // must come off the model word in the same breath — with the OTHER half of + // the door naming whose pin it was, so a row can say `auto (…)` rather + // than falling silent about a machine somebody wrote down. + if !retirePin("Ghost", model) { + t.Fatal("the refusal did not retire the pin") + } + if stood := agree("retired"); !strings.EqualFold(stood, "Ghost") { + t.Fatalf("a retired pin stood down %q, want Ghost — a surface has nothing to name it by", stood) + } + + // 5. AND A PIN RETIRED FOR ONE MODEL IS UNTOUCHED FOR ANOTHER, because the + // refusal was about a pairing. + other := model + "-elsewhere" + if named, _ := PinNow(other); !strings.EqualFold(named, "Ghost") { + t.Fatalf("a pin retired for one model reads %q for another", named) + } + + // 6. PINNING AGAIN PUTS IT STRAIGHT BACK, which is what the manual promises + // and what makes `enter` on a stood-down machine a re-pin rather than the + // unpin the same key is everywhere else in that fold. + RepinLane(LanePin{Lane: "Ghost"}) + if named, stood := PinNow(model); !strings.EqualFold(named, "Ghost") || stood != "" { + t.Fatalf("re-pinning left the pin at %q stood down as %q", named, stood) + } + agree("re-pinned") + + // 7. A BASE THAT WILL NOT CARRY A LANE CHOICE AT ALL. Nothing is demanded, so + // nothing may be named — and it is not a stand-down either: the row was + // never refused, the base simply does not take it, and that has its own + // sentence ([UncarriedPinLine]). + const base = "https://proxy.example/v1" + lanes.WireSheet(base, "", nil, false) + t.Cleanup(func() { lanes.WireSheet("", "", nil, false) }) + if !lanes.HeardPrefsSilent(base) { + t.Fatal("the base's answer was not filed") + } + if named, stood := PinNow(model); named != "" || stood != "" { + t.Fatalf("on a base that takes no lane choice the chip reads %q / %q", named, stood) + } +} + +// AND THE OLD DOOR IS THE NEW ONE'S FIRST HALF. [PinnedFor] is what the surface +// called before this pair existed and what several callers still call; a build +// where it answered anything else would be two readings of one fact again. +func TestPinnedForIsTheFirstHalfOfPinNow(t *testing.T) { + rig := newLaneRig(t, "chip/one-door", retiredLanes()...) + forgotten(t) + pinned(t, LanePin{Lane: "Harbor"}) + for _, model := range []string{rig.model, rig.model + "-elsewhere"} { + named, _ := PinNow(model) + if PinnedFor(model) != named { + t.Fatalf("PinnedFor(%q) is %q and PinNow is %q", model, PinnedFor(model), named) + } + } + if !retirePin("Harbor", rig.model) { + t.Fatal("the refusal did not retire the pin") + } + named, stood := PinNow(rig.model) + if PinnedFor(rig.model) != named || named != "" { + t.Fatalf("after the retirement PinnedFor is %q and PinNow is %q", PinnedFor(rig.model), named) + } + // THE SENTENCE AND THE ROW'S OWN TAIL ARE SPELLED FROM THE SAME NAME, so a + // surface drawing the short one cannot name a different machine from the one + // the conversation was told about. + if !strings.Contains(RetiredPinLine(stood), stood) || !strings.Contains(RetiredPinTail(stood), stood) { + t.Fatalf("the retirement's two spellings do not both name %q", stood) + } +} diff --git a/internal/provider/velocity.go b/internal/provider/velocity.go index fff6a29b98..40d2d504d3 100644 --- a/internal/provider/velocity.go +++ b/internal/provider/velocity.go @@ -245,6 +245,22 @@ func InstallRouting(strategy RoutingStrategy) { installedRouting.Store(strategy) } +// RoutingNow is the row in force for a caller that hands down none of its own: +// what this process installed, and [DefaultRouting] where nobody installed +// anything. +// +// It is exported for the gates OUTSIDE this package that have to answer the +// same question the request path answers ([Client.routingChoice]) — the lane +// beat in internal/session asks whether the row is `off` before it measures +// anything. A session that carries no row of its own must read it HERE and not +// off a field filled in at launch, because the row moves while the process runs: +// the settings panel writes it and re-installs it in the same keystroke, and a +// launch snapshot would keep a beat running that a person has just turned off. +func RoutingNow() RoutingStrategy { + strategy, _ := installedRoutingChoice() + return strategy +} + // installedRoutingChoice is the installed row and whether one was installed at // all, in the shape [Client.routingChoice] answers in. With none installed the // strategy is [DefaultRouting] and the answer to "did somebody choose?" is no, diff --git a/internal/session/agent.go b/internal/session/agent.go index 1ea51a5b2f..462981ee9a 100644 --- a/internal/session/agent.go +++ b/internal/session/agent.go @@ -471,7 +471,7 @@ func (a *Agent) startLaneBeat() { // (lanenews.go's [Agent.Typing]), and a session that runs no beat may still // buy a measurement. It is cancelled once, by Close. a.laneCtx, a.laneStop = context.WithCancel(context.Background()) - if a.config.Routing == provider.RoutingOff { + if a.config.routingInForce() == provider.RoutingOff { return } models := laneBeatModels(a.config) diff --git a/internal/session/clientdoor.go b/internal/session/clientdoor.go index 8b4322fe41..8a5a597a95 100644 --- a/internal/session/clientdoor.go +++ b/internal/session/clientdoor.go @@ -189,8 +189,27 @@ func accountForService(service modelsource.Connected) modelAccount { // newProviderClient is construction shared by New and a live cross-service // switch. The same routing and fallback seams must survive replacement; a // second, smaller constructor would silently change how the next turn runs. +// routingInForce is the row THIS SESSION'S calls answer to: its own where a +// caller handed one down, and the row installed in this process otherwise. +// +// It is the same rule the adapter applies one layer down +// ([provider.Client.routingChoice]) and it is stated here because the gates on +// this side have to agree with it — a lane beat that stayed running because a +// launch snapshot said `latency` would be measuring endpoints for a person who +// has since turned routing off. +func (c Config) routingInForce() provider.RoutingStrategy { + if strings.TrimSpace(string(c.Routing)) != "" { + return c.Routing + } + return provider.RoutingNow() +} + func newProviderClient(config Config, model string) (*provider.Client, error) { settings := config.clientConfig(model, providerTimeout) + // AN EMPTY ROW HERE IS NOBODY HAVING CHOSEN, and that is what a launch from + // a profile leaves behind: the client then reads the row this process + // installed, live, on every call ([Config.Routing] says why). A caller that + // really carries a row of its own still hands it down and still wins. settings.Routing = provider.StaticRouting(config.Routing) settings.Fallbacks = config.ModelFallbacks settings.NearestModels = config.NearestModels diff --git a/internal/session/routinglive_test.go b/internal/session/routinglive_test.go new file mode 100644 index 0000000000..4e80c81aa9 --- /dev/null +++ b/internal/session/routinglive_test.go @@ -0,0 +1,42 @@ +package session + +import ( + "testing" + + "github.com/Agent-Field/aforge-v2/internal/provider" +) + +// A SESSION THAT CARRIES NO ROW OF ITS OWN READS THE LIVE ONE. The row a person +// wrote is installed process-wide and moves while the process runs — the +// settings panel writes it and re-installs it in the same keystroke — so a gate +// on this side that read a field filled in at launch would be measuring +// endpoints for somebody who has since turned routing off (issue #1022). +// +// A caller that really carries a row — an engine host, a task child — still +// hands one down, and a handed-down answer still wins. +func TestASessionsRoutingFollowsTheInstalledRowUnlessItCarriesItsOwn(t *testing.T) { + before := provider.RoutingNow() + t.Cleanup(func() { provider.InstallRouting(before) }) + + fromProfile := Config{} + provider.InstallRouting(provider.RoutingLatency) + if got := fromProfile.routingInForce(); got != provider.RoutingLatency { + t.Fatalf("a session handed no row routes by %q, want the installed latency", got) + } + provider.InstallRouting(provider.RoutingOff) + if got := fromProfile.routingInForce(); got != provider.RoutingOff { + t.Fatalf("the row moved to off and the session still reads %q", got) + } + + carried := Config{Routing: provider.RoutingPrice} + if got := carried.routingInForce(); got != provider.RoutingPrice { + t.Fatalf("a session carrying its own row reads %q, want price", got) + } + + // AND WITH NOTHING INSTALLED AT ALL the answer is the row this build ships, + // which is the same answer the adapter gives such a caller. + provider.InstallRouting("") + if got := fromProfile.routingInForce(); got != provider.DefaultRouting { + t.Fatalf("with no row installed the session reads %q, want the shipped row", got) + } +} diff --git a/internal/session/session.go b/internal/session/session.go index a6e33844e8..d2fa682158 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -1115,6 +1115,14 @@ type Config struct { // velocity.go). EMPTY IS NOBODY HAVING CHOSEN: the session falls to the row // this process installed and, with none installed, to the shipped row // ([provider.DefaultRouting]), which sends no preference of ours at all. + // + // AND EMPTY IS WHAT A LAUNCH FROM A PROFILE LEAVES IT AT, on purpose: the + // profile's row is installed process-wide instead (internal/config's + // InstallLaneRows), so a person who cycles `routing` in the settings panel + // is answered by the very next request rather than by the next launch + // (issue #1022). What still fills this field is a caller carrying a row that + // is not this process's to read — an engine host, a task child — and such a + // caller still wins over the installed row. Routing provider.RoutingStrategy // CompactEnabled gates automatic compaction. Manual compaction via the diff --git a/internal/tui3/app.go b/internal/tui3/app.go index e7b578ae5b..3b3eefde20 100644 --- a/internal/tui3/app.go +++ b/internal/tui3/app.go @@ -2281,11 +2281,14 @@ type app struct { // settings should not open one. profileDir string settings *config.Settings - // routing is the routing row this session was launched under, in the words - // the row itself is written in — `latency`, `price`, `simple` or `off` - // (internal/config's settings.go). It is read ONCE, here, because that is - // when the session reads it — the row lands on the next session — and the - // chrome that asks it does so on every frame. + // routing is the routing row in force, in the words the row itself is + // written in — `latency`, `price`, `simple` or `off` (internal/config's + // settings.go). It is read here at launch because the chrome that asks it + // does so on every frame and may not read a file to answer, and it is READ + // AGAIN the moment this surface writes the row (lanes.go's + // [app.routingRowChanged]) — a change lands on the next message, so a field + // that only moved at launch would leave the panel explaining `auto` in the + // word a person had just replaced. // // THE WHOLE ROW IS KEPT AND NOT ONE READING OF IT. This was a `routingOff // bool`, which answered the only question the surface had while the row had diff --git a/internal/tui3/lanes.go b/internal/tui3/lanes.go index dbca18cc65..42e79e0908 100644 --- a/internal/tui3/lanes.go +++ b/internal/tui3/lanes.go @@ -517,13 +517,50 @@ func laneFor(views []laneView, name string) (laneView, bool) { // ── WHICH LANE IS ANSWERING ───────────────────────────────────────────────── +// laneForce is WHAT THE WIRE WILL DO WITH THE PIN, read once: the machine the +// next request for this model will demand, and the machine the person's row +// still names after the wire has stopped asking for it. +type laneForce struct { + // name is the machine the next request demands, and empty when it demands + // none — `auto`, `openrouter`, a pairing the wire has retired, or a base + // that carries no lane choice at all. + name string + // standDown is the machine the `lane` row names while nothing asks for it, + // and empty while the row and the wire agree. It is what lets a row say + // `auto` and still say whose name came off it. + standDown string +} + +// laneInForce is THE ONE READING every place on this surface that names the +// machine behind the conversation's model makes — the chip on the model word, +// the settings panel's `lane` tail, the mark inside the picker's fold, and the +// `via` on a row. +// +// IT IS THE TRANSPORT'S ANSWER AND NEVER THE PROFILE ROW'S ([provider.PinNow]). +// The chip was drawn from the row while the wire asked the transport, and on +// 2026-09-13 a pairing the wire had retired mid-session left `@morph` on the +// model word over three turns another machine answered (issue #1022). A surface +// may name a machine only while a request would demand it. The row a person +// wrote is still theirs and still unchanged on disk — it is on the `lane` row, +// which is where they go to look at it, and pinning again puts it straight back. +// +// AND THE MODEL IS FOLDED BY THE TRANSPORT AND NOT HERE, which is the other +// half of that defect: the retirement is written under the ledger's spelling of +// the model and this surface holds its own, so the question goes through the +// door that folds rather than against a key built on this side. +func laneInForce(model string) laneForce { + demanded, standDown := provider.PinNow(model) + return laneForce{name: demanded, standDown: standDown} +} + // laneNow is the lane a request for this model would go to, and false when // nothing here can say. THE RULE IS THREE RUNGS AND IT IS STATED ON PURPOSE, // because a `via` on a picker row that disagreed with the `via` on the status // line would be two answers to one question: // -// 1. A PIN WINS. If the slot's row names a machine, that is the machine, and -// the surface never second-guesses a person who has chosen. +// 1. A PIN WINS — the pin THE WIRE WILL ACT ON ([laneInForce]) and not the row +// on disk, which are the same fact until the wire retires a pairing and the +// row goes on naming a machine nothing asks for. // 2. THEN THE CHOOSER. It is pure and cheap, so the picker asks it exactly // what a request would ask it, and draws the top of the order it gets // back. An empty chooser has no opinion, which is a real answer. @@ -534,7 +571,7 @@ func laneFor(views []laneView, name string) (laneView, bool) { // It is deliberately not "whoever served last": the last lane is a fact about // the previous answer, and this row is a claim about the next one. func (a *app) laneNow(model string, views []laneView) (string, bool) { - if name, pinned := config.LanePinned(a.profileDir, laneSlotFor(model)); pinned { + if name := laneInForce(model).name; name != "" { return name, true } name := laneAuto(a.routing, model, views, a.now()) @@ -631,6 +668,10 @@ func (a *app) armLanes(p *picker, slot string) { } p.laneSlot = slot p.pin = config.LaneAt(a.profileDir, slot) + // AND THE MACHINE THE WIRE WOULD ACTUALLY ASK FOR, beside the row and not + // instead of it. The row tells the fold's three rungs apart and this is what + // any row that draws a machine's NAME may draw ([picker.force]). + p.force = a.pinnedNow() p.guard = config.LaneGuardAt(a.profileDir) p.ascii = a.pal.ascii } @@ -651,6 +692,19 @@ func (a *app) routingOff() bool { return a.routing == config.RoutingOff } // (palette.go's [app.pickerKey]) and the settings panel's model and lane rows // (settings.go's [app.sheetSelectKey]) — and a pin written two ways is a pin // that drifts the first time one of the two is fixed. +// ENTER ON THE MACHINE ALREADY IN FORCE TAKES THE PIN OFF. It is a toggle on +// the same key that put the pin on, which is the only way back to `auto` a +// person can find without being told: the `auto` row sits above every machine in +// the fold, so clearing a pin was sixteen presses of `↑` past all of them — and +// one press too many lands on another model's row, where enter switches the +// model instead (issue #1022). The `auto` row is still there and still the +// explicit way to say it. +// +// IT IS KEYED ON WHAT THE WIRE WOULD DEMAND ([laneInForce]) AND NOT ON THE ROW, +// which is what keeps the other promise the manual makes: once the wire has +// retired a pairing the row still names that machine and nothing is asking for +// it, so enter there is a person saying "try again" and puts the pin straight +// back rather than quietly clearing the row they were re-stating. func (a *app) applyLaneChoice(model string, row pickRow, lanes []laneView) { switch { case row.lane == laneAutoAt: @@ -658,7 +712,11 @@ func (a *app) applyLaneChoice(model string, row pickRow, lanes []laneView) { case row.lane == laneRoutAt: a.setLaneRouterOnly(model) case row.lane >= 0 && row.lane < len(lanes): - a.pinLane(model, lanes[row.lane].Name) + if name := lanes[row.lane].Name; strings.EqualFold(laneInForce(model).name, name) { + a.clearLanePin(model) + } else { + a.pinLane(model, name) + } } } @@ -1220,6 +1278,36 @@ func (a *app) laneRowChanged() { provider.RepinLane(config.LanePinAt(a.profileDir, slot)) } +// routingRowChanged is [app.laneRowChanged] for the row ABOVE the lane: the +// routing word this surface just wrote, handed to the transport and read back +// into everything on this side that explains it. +// +// A ROUTING CHANGE LANDS ON THE NEXT MESSAGE AND NOT THE NEXT SESSION. The row +// went to disk and nowhere else, and the session's own clients had been handed +// the word that was there at launch — so a person cycled `routing` to `simple`, +// watched the row say so, and went on being routed by the old word until they +// relaunched, with the `lane` row beside it still explaining `auto` in the old +// word's terms on the same screen (issue #1022). The transport's own knob is +// process-wide for exactly this ([config.InstallRoutingRow]), the conversation's +// clients read it live, and the two fields below are what this surface says +// about it. +// +// THE FIELDS ARE RE-READ AND NOT ASSUMED. [app.routing] is the row RESOLVED — +// an unwritten or unknown word is the shipped row — so the surface asks the one +// door that resolves it rather than keeping the raw word it just applied. +func (a *app) routingRowChanged() { + if a.hosted() { + return + } + config.InstallRoutingRow(a.profileDir) + a.routing = config.RoutingAt(a.profileDir) + // The panel holds its own copy for the reason it holds the session's model + // (settings.go's [sheet.routing]), and the copy is what the `lane` row's + // explanation is drawn from — so a row that changed on this frame must not + // leave the sentence under it describing the row before it. + a.sheet.routing = a.routing +} + // ── THE PIN, WRITTEN ON THE MODEL ─────────────────────────────────────────── // laneAtSign is what joins a model to the machine it is pinned to. It is the @@ -1228,16 +1316,33 @@ func (a *app) laneRowChanged() { const laneAtSign = "@" // pinnedNow is the machine this conversation's requests are held to, as the -// transport will act on it ([provider.PinnedFor]), lowercased the way every -// lane name this surface draws is — and empty on `auto`, on `openrouter`, on a -// pin the wire has retired for this model, under routing `off` (which sends no -// lane at all, [app.routingOff]), and over a connection, where the pin in force -// is the far machine's and this process cannot see it. +// transport will act on it ([laneInForce]), lowercased the way every lane name +// this surface draws is — and empty on `auto`, on `openrouter`, on a pin the +// wire has retired for this model, under routing `off` (which sends no lane at +// all, [app.routingOff]), and over a connection, where the pin in force is the +// far machine's and this process cannot see it. func (a *app) pinnedNow() string { + return strings.ToLower(a.laneForceNow().name) +} + +// laneStoodDown is the machine the `lane` row still names while no request asks +// for it, lowercased like every other lane name here and empty while the row and +// the wire agree. It is the half of [laneInForce] a surface needs in order to +// say `auto` and still say whose name came off. +func (a *app) laneStoodDown() string { + return strings.ToLower(a.laneForceNow().standDown) +} + +// laneForceNow is [laneInForce] for the model this conversation is on, with the +// four states where this surface may name no machine at all answered first: a +// session over a connection (the pin is the far machine's and this process +// 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) { - return "" + return laneForce{} } - return strings.ToLower(provider.PinnedFor(a.model)) + return laneInForce(a.model) } // modelWord is THE MODEL AS THE CHROME NAMES IT: its basename ([modelBase]), diff --git a/internal/tui3/palette.go b/internal/tui3/palette.go index dc0c7640e5..b9d3b81f62 100644 --- a/internal/tui3/palette.go +++ b/internal/tui3/palette.go @@ -89,11 +89,19 @@ type picker struct { // chooser decides where a request goes — and the `auto` row is a claim about // the second of those. Empty when nothing is believed, which draws no name. auto string - // pin is the lane this conversation is held to, empty for auto. It is a - // snapshot taken when the list opened, exactly as current is, and for the - // same reason: it answers "what am I on", which cannot change while a modal - // overlay owns the keyboard. + // pin is the lane ROW this conversation is held to, VERBATIM — a machine's + // name, `auto` or `openrouter` — which is what tells the three rungs of the + // fold apart ([picker.marked]). It is a snapshot taken when the list opened, + // exactly as current is, and for the same reason: it answers "what am I on", + // which cannot change while a modal overlay owns the keyboard. pin string + // force is the machine the next request would actually DEMAND ([app.pinnedNow]), + // and it is what every row that NAMES a machine draws. It differs from the + // row above it exactly when the wire has retired the pairing: the row goes on + // saying morph and no request asks for it, and the fold then marks `auto`, + // which is where the requests are really going (issue #1022). A snapshot for + // [picker.pin]'s reason. + force string // guard is whether a slow answer may be rescued elsewhere. It rides here // because it is a fact about what `auto` PROMISES, and this list is where a // person decides whether to leave the choosing to it. @@ -287,8 +295,14 @@ func (p *picker) rank() { // same query and gets the same list. tokens, terms := splitQuery(p.filter.String()) now := timeNow() + // AN OPEN FOLD IS THE SUBJECT OF WHAT IS TYPED NEXT, and it is read before + // the fold is forgotten below ([picker.narrowFold]). + open := p.unfold p.hits = p.hits[:0] p.unfold, p.lanes, p.first, p.auto = "", nil, "", "" + if p.narrowFold(open, tokens, terms, now) { + return + } for i, id := range p.lower { if len(terms) > 0 && !keepsLanes(p.all[i], terms, now) { continue @@ -342,6 +356,78 @@ func (p *picker) rank() { } } +// narrowFold answers the filter box AS A QUESTION ABOUT THE MACHINES ALREADY ON +// SCREEN, and false when it is not one — in which case the box filters models +// exactly as it always has. +// +// WHAT IS OPEN IS WHAT IS BEING ASKED ABOUT. Somebody who has walked into a +// model's fold and typed `morph` is looking at a list of machines and narrowing +// it; the shipped reading took the same keystrokes as a hunt for a MODEL, found +// `morph/morph-v3-large` in the catalog, and closed the fold they were standing +// in (issue #1022). So an open fold gets the tokens first, and only a query that +// matches none of its machines falls through to the models. +// +// IT IS THE SAME MATCHER AND NOT A SECOND ONE ([tokenScore], every token ANDed), +// so `cloud fl` finds Cloudflare in a fold exactly as it finds a model in the +// list, and a lane term (`@name`, `<1s`) is left to the list because those are +// questions about which MODELS to keep. +func (p *picker) narrowFold(model string, tokens []string, terms []laneTerm, now time.Time) bool { + if model == "" || len(tokens) == 0 || len(terms) > 0 { + return false + } + at := -1 + for i, hit := range p.all { + if hit.ID == model { + at = i + break + } + } + if at < 0 { + return false + } + views := laneViews(model, now) + kept := make([]laneView, 0, len(views)) + for _, view := range views { + if matchesEvery(strings.ToLower(view.Name), tokens) { + kept = append(kept, view) + } + } + if len(kept) == 0 { + return false + } + // The fold's own model is the only hit, so the machines that matched are + // drawn under the name they serve and nothing else is on the screen to + // wonder about. The `auto` row's prediction is taken over ALL the views: it + // is a claim about where the next turn goes and not about what was typed. + p.hits = append(p.hits, at) + p.unfold, p.lanes, p.first = model, kept, "" + p.auto = laneAuto(p.routing, model, views, now) + p.relist() + p.top = 0 + // AND THE CURSOR LANDS ON THE FIRST MACHINE THAT MATCHED rather than on the + // `auto` row above them, because the machines are what was asked for and + // enter is what happens next. + for i, row := range p.list { + if row.lane >= 0 { + p.cursor = i + break + } + } + p.follow(pickerRows) + return true +} + +// matchesEvery is [tokenScore]'s yes-or-no half over a whole query: every token +// has to match, which is the same ANDing the model list does. +func matchesEvery(text string, tokens []string) bool { + for _, token := range tokens { + if _, hit := tokenScore(text, token); !hit { + return false + } + } + return true +} + // splitQuery divides what is typed into the words that rank and the terms that // filter. Every token that does not parse is a word, unchanged. func splitQuery(query string) ([]string, []laneTerm) { @@ -1366,24 +1452,28 @@ func (p *picker) rowsOwned(width, n int, pal palette, hover int, level func(stri } // pinnedLane is the MACHINE this conversation is held to, and empty for every -// row that names none — which is both `auto` and `openrouter`. +// row that names none — `auto`, `openrouter`, and a pairing the wire has retired. // // It exists because [picker.pin] is the settings row VERBATIM, which is what // [picker.marked] needs to tell the three rungs of the fold apart, and is // exactly the wrong thing to hand to anything that draws a lane's name: the row // reading `auto` drew a model whose speed came `via auto`, a machine no router -// has ever heard of. -func (p *picker) pinnedLane() string { - switch strings.ToLower(strings.TrimSpace(p.pin)) { - case "", config.LaneAuto, config.LaneOpenRouter: - return "" - } - return p.pin -} +// has ever heard of. It is [picker.force] and not that row for the second half +// of the same rule — a name may be drawn only while a request would demand it +// (lanes.go's [laneInForce]). +func (p *picker) pinnedLane() string { return p.force } // marked is the row an overlay's chosen band belongs to: the model in use, and // — inside an open fold — the lane this conversation is actually held to, which -// is the pin when there is one and the `auto` row when there is not. +// is the machine the next request would demand when there is one and the `auto` +// row when there is not. +// +// THE MACHINE ROWS ARE MARKED AGAINST WHAT THE WIRE WOULD DEMAND and the two +// answers that name no machine against the ROW, because those two are the only +// thing the row says that the wire cannot: `auto` and `openrouter` both send no +// lane, and only the row knows which of them a person wrote. So a pin the wire +// has retired marks `auto` — the requests are going there — while the row itself +// still reads `pinned: morph` on the panel that owns it. func (p *picker) marked(at int) bool { if p.rowUnavailable(at) { return false @@ -1396,11 +1486,11 @@ func (p *picker) marked(at int) bool { case model.ID != p.current: return false case row.lane == laneAutoAt: - return p.pin == "" || strings.EqualFold(p.pin, config.LaneAuto) + return p.force == "" && !strings.EqualFold(p.pin, config.LaneOpenRouter) case row.lane == laneRoutAt: return strings.EqualFold(p.pin, config.LaneOpenRouter) } - return strings.EqualFold(p.pin, p.lanes[row.lane].Name) + return p.force != "" && strings.EqualFold(p.force, p.lanes[row.lane].Name) } // groupBefore is the dim service heading before a model row. Folded lane rows @@ -1769,6 +1859,13 @@ const ( // pickerKeysFoldTab is the same with characters before the caret, where // `←` edits the box and `tab` is the way out. pickerKeysFoldTab = "enter choose · tab back · esc" + // pickerKeysUnpin is the row inside the fold that the requests are ALREADY + // going to: the same enter takes the pin off there (lanes.go's + // [app.applyLaneChoice]), and the hint is the only place that gesture + // announces itself. + pickerKeysUnpin = "enter unpin · ← back · esc" + // pickerKeysUnpinTab is that row with characters before the caret. + pickerKeysUnpinTab = "enter unpin · tab back · esc" // pickerKeysSwitch is a list with no fold at all — a task's model, an // empty result — where the keys are the two every list has. pickerKeysSwitch = "enter switch · esc" @@ -1789,9 +1886,16 @@ func (p *picker) keysHint() string { if ok { row = p.list[p.cursor] } + // AND A MACHINE THAT IS ALREADY THE ANSWER SAYS WHAT ENTER DOES THERE, which + // is the one gesture in this fold that is not the same as its neighbours'. + unpin := ok && row.lane >= 0 && p.marked(p.cursor) switch { case !ok || p.laneSlot == "": return pickerKeysSwitch + case unpin && p.filter.cursor > 0: + return pickerKeysUnpinTab + case unpin: + return pickerKeysUnpin case row.lane != laneNone && p.filter.cursor > 0: return pickerKeysFoldTab case row.lane != laneNone: diff --git a/internal/tui3/pinchip_test.go b/internal/tui3/pinchip_test.go new file mode 100644 index 0000000000..e418f99582 --- /dev/null +++ b/internal/tui3/pinchip_test.go @@ -0,0 +1,221 @@ +package tui3 + +import ( + "strings" + "testing" + + "github.com/Agent-Field/aforge-v2/internal/config" + "github.com/Agent-Field/aforge-v2/internal/provider" +) + +// ── THE PIN A SURFACE NAMES IS THE PIN THE WIRE WOULD DEMAND ──────────────── +// +// Issue #1022. The chip was drawn from the profile row and the wire asked the +// transport, so a pairing the wire retired mid-session left `@morph` on the +// model word over three turns another machine answered. The law in +// internal/provider says the two doors answer one question; these say this +// surface asks through that door and not around it. + +// TRANSPORT ON AUTO, ROW STILL NAMING A MACHINE — the exact disagreement, made +// here by moving the transport rather than by collecting a 404, because what is +// under test is which of the two the surface reads. +func TestTheSurfaceNamesNoMachineTheTransportIsNotAskingFor(t *testing.T) { + laneLab(t, threeLanes()) + a := laneApp(t) + a.width, a.height = 120, 24 + a.pinLane(flash, "cloudflare") + if a.modelWord() != "deepseek-v4-flash@cloudflare" { + t.Fatalf("the pin did not reach the model word: %q", a.modelWord()) + } + + // The row on disk is untouched and still names the machine; the transport + // has stopped asking for it. + provider.SetLanePin(provider.LanePin{}) + if name, pinned := config.LanePinned(a.profileDir, talkSlot); !pinned || !strings.EqualFold(name, "cloudflare") { + t.Fatalf("the row stopped naming the machine (%q, %v), so this test is not about the disagreement it names", name, pinned) + } + + if word := a.modelWord(); strings.Contains(word, laneAtSign) { + t.Fatalf("the model word still names a machine nothing is asking for: %q", word) + } + if got := a.pinnedNow(); got != "" { + t.Fatalf("the chip reads %q over requests that demand no machine", got) + } + if name, ok := a.laneNow(flash, laneViews(flash, a.now())); ok && strings.EqualFold(name, "cloudflare") { + t.Fatal("a picker row would still say via cloudflare") + } + + // AND THE FOLD MARKS `auto`, which is where the requests are going — not the + // machine the row names. + typeLine(t, a, "/model") + drive(t, a, key("right")) + screen := plain(frame(a)) + if line := screenLine(screen, "auto"); !strings.Contains(line, "●") { + t.Fatalf("the fold does not mark auto:\n%s", screen) + } + if line := screenLine(screen, "cloudflare"); strings.Contains(line, "●") { + t.Fatalf("the fold marks a machine nothing is asking for:\n%s", screen) + } +} + +// THE MODEL ROW'S TAIL, OVER EVERY STATE THE ROW AND THE WIRE CAN BE IN. It is +// one function of those two facts ([laneRowTail]) so that the states can be read +// back in one place. +func TestTheModelRowsTailNamesTheMachineOnlyWhileItIsAskedFor(t *testing.T) { + for _, probe := range []struct { + state string + row string + force laneForce + want string + }{ + {"auto", config.LaneAuto, laneForce{}, ""}, + {"nothing written", "", laneForce{}, ""}, + {"pinned and carried", "pinned: Cloudflare", laneForce{name: "Cloudflare"}, "pinned: cloudflare"}, + {"openrouter", config.LaneOpenRouter, laneForce{}, config.LaneOpenRouter}, + { + "retired", "pinned: Morph", laneForce{standDown: "Morph"}, + "auto (morph cannot serve this model)", + }, + } { + if got := laneRowTail(probe.row, probe.force); got != probe.want { + t.Fatalf("%s: the tail reads %q, want %q", probe.state, got, probe.want) + } + } + // AND THE RETIRED TAIL IS THE SENTENCE'S OWN SPELLING, never a second one + // written here. + if want := provider.RetiredPinTail("morph"); !strings.HasSuffix(laneRowTail("pinned: Morph", laneForce{standDown: "Morph"}), want) { + t.Fatalf("the tail does not end in %q", want) + } +} + +// ── THE FOLD'S TWO GESTURES ───────────────────────────────────────────────── + +// ENTER ON THE MACHINE ALREADY IN FORCE TAKES THE PIN OFF, and the hint slot +// says so while the cursor is on that row. Going back to `auto` was otherwise a +// walk up past every machine in the fold, with another model's row — where enter +// switches the model — one press further (issue #1022). +func TestEnterOnThePinnedMachineUnpinsIt(t *testing.T) { + laneLab(t, threeLanes()) + a := laneApp(t) + a.width, a.height = 130, 40 + a.pinLane(flash, "cloudflare") + + typeLine(t, a, "/model") + drive(t, a, key("right")) + row, on := a.pick.laneUnder() + if !on || row.lane < 0 || !strings.EqualFold(a.pick.lanes[row.lane].Name, "cloudflare") { + t.Fatalf("the walk did not land on the pinned machine: %+v (on=%v)", row, on) + } + if got := a.hintWord(); !strings.HasPrefix(got, pickerKeysUnpin) { + t.Fatalf("on the pinned machine the hint reads %q, want %q", got, pickerKeysUnpin) + } + + drive(t, a, key("enter")) + if got := a.pinnedNow(); got != "" { + t.Fatalf("enter on the pinned machine left the pin at %q", got) + } + if word := config.LaneAt(a.profileDir, talkSlot); !strings.EqualFold(word, config.LaneAuto) { + t.Fatalf("the row reads %q after the unpin, want auto", word) + } + + // AND ENTER ON A MACHINE THAT IS NOT IN FORCE STILL PINS IT, which is the + // other half of a toggle. + typeLine(t, a, "/model") + drive(t, a, key("right"), key("down"), key("enter")) + if got := a.pinnedNow(); got == "" { + t.Fatal("enter on an unpinned machine did not pin it") + } +} + +// TYPING WHILE A FOLD IS OPEN FILTERS THAT MODEL'S MACHINES. The shipped reading +// took the same keystrokes as a hunt for a MODEL — `morph` matched +// `morph/morph-v3-large` in the catalog — and closed the fold the person was +// standing in. +func TestTypingInsideAnOpenFoldFiltersTheMachines(t *testing.T) { + laneLab(t, threeLanes()) + a := laneApp(t) + a.width, a.height = 130, 40 + typeLine(t, a, "/model") + drive(t, a, key("right")) + + typeInto(t, a, "core") + if a.pick.unfold != flash { + t.Fatalf("typing inside the fold closed it (fold %q)", a.pick.unfold) + } + if len(a.pick.lanes) != 1 || !strings.EqualFold(a.pick.lanes[0].Name, "coreweave") { + t.Fatalf("the fold holds %d machines, want coreweave alone: %+v", len(a.pick.lanes), a.pick.lanes) + } + row, on := a.pick.laneUnder() + if !on || row.lane != 0 { + t.Fatalf("the cursor is not on the machine that matched: %+v (on=%v)", row, on) + } + drive(t, a, key("enter")) + if got := a.pinnedNow(); !strings.EqualFold(got, "coreweave") { + t.Fatalf("enter on the matched machine pinned %q", got) + } + + // AND A QUERY NO MACHINE MATCHES STILL FILTERS THE MODELS, closing the fold + // with it — the fall-through that keeps one filter box doing one thing. + typeLine(t, a, "/model") + drive(t, a, key("right")) + typeInto(t, a, "kimi") + if a.pick.unfold != "" { + t.Fatalf("a query matching no machine left the fold open at %q", a.pick.unfold) + } + if chosen, _ := a.pick.choice(); chosen.ID != "moonshotai/kimi-k3" { + t.Fatalf("the model filter landed on %q", chosen.ID) + } +} + +// ── A ROUTING CHANGE LANDS ON THE NEXT MESSAGE ────────────────────────────── +// +// The row went to disk and nowhere else: the surface's reading of it and the +// session's clients were both launch snapshots, so a person cycled `routing`, +// watched the row change, and went on being routed by the old word — with the +// `lane` row beside it still explaining `auto` in that old word on the same +// screen (issue #1022). +func TestARoutingChangeReachesTheTransportAndThePanelAtOnce(t *testing.T) { + laneLab(t, threeLanes()) + before := provider.RoutingNow() + t.Cleanup(func() { provider.InstallRouting(before) }) + dir := t.TempDir() + row, ok := config.NewSettings(config.SettingsOptions{ProfileDir: dir}).Row(config.KeyRouting) + if !ok || row.Apply(config.RoutingLatency) != nil { + t.Fatal("could not write the routing row") + } + t.Setenv("AFORGE_HOME", t.TempDir()) + a := newApp(t.Context(), Options{Agent: &fakeAgent{model: flash}, Workspace: "/tmp/lab", ProfileDir: dir}) + a.models = func() []Model { return laneCatalog } + a.width, a.height = 130, 40 + provider.InstallRouting(provider.RoutingLatency) + + a.openSettings() + if a.sheet.routing != config.RoutingLatency { + t.Fatalf("the panel opened under routing %q", a.sheet.routing) + } + meta, found := settingMetaFor(mustRow(t, a, config.KeyRouting)) + if !found { + t.Fatal("the panel has no routing row") + } + a.applySetting(sheetItem{row: mustRow(t, a, config.KeyRouting), meta: meta}, config.RoutingSimple) + + // THE TRANSPORT, FIRST: the very next request is the one this has to reach. + if got := provider.RoutingNow(); got != provider.RoutingSimple { + t.Fatalf("after the write the transport routes by %q, want simple", got) + } + // AND THE SURFACE'S OWN TWO READINGS, on the same frame. + if a.routing != config.RoutingSimple || a.sheet.routing != config.RoutingSimple { + t.Fatalf("the surface reads routing %q / %q after the write", a.routing, a.sheet.routing) + } + laneMeta, ok := a.sheet.metaFor(mustRow(t, a, config.LaneSettingKey(talkSlot))) + if !ok || !strings.Contains(laneMeta.about, "openrouter's own routing answers") { + t.Fatalf("the lane row is still explained as %q", laneMeta.about) + } + // AND THE FOLD THE SAME ROW OPENS SAYS IT TOO. + a.closeSettings() + typeLine(t, a, "/model") + drive(t, a, key("right")) + if screen := plain(frame(a)); !strings.Contains(screen, "openrouter's own routing; aforge stays out") { + t.Fatalf("the fold still promises the old row's takeover:\n%s", screen) + } +} diff --git a/internal/tui3/rowfit_test.go b/internal/tui3/rowfit_test.go index ec1bec0604..70ad099746 100644 --- a/internal/tui3/rowfit_test.go +++ b/internal/tui3/rowfit_test.go @@ -38,7 +38,10 @@ func rowfitPicker(t *testing.T) *app { // (palette.go's [laneAutoSaid]). a.routing = config.RoutingLatency typeLine(t, a, "/model") - a.pick.pin = "CoreWeave" + // THE ROW AND WHAT THE WIRE WOULD DEMAND ARE BOTH SET, because they are two + // different fields on the list and the row a machine's NAME is drawn from is + // the second ([picker.force]). + a.pick.pin, a.pick.force = "CoreWeave", "CoreWeave" return a } diff --git a/internal/tui3/settings.go b/internal/tui3/settings.go index 2826ae0058..817381f850 100644 --- a/internal/tui3/settings.go +++ b/internal/tui3/settings.go @@ -686,7 +686,7 @@ var settingUI = map[string]settingMeta{ "answers, and a pinned lane is the whole request; latency asks for the fastest " + "and demotes one that keeps being slow; price asks for the cheapest; off asks " + "for nothing, measures nothing, and leaves the two rows above it with no " + - "machine to name.", + "machine to name. a change here takes effect on your next message.", }, // AND UNDER IT, THE MACHINE ITSELF. routing is about what every request // prefers; this is about which endpoint your conversation actually lands on. @@ -904,11 +904,13 @@ type sheet struct { // defaults is every row's reading on a profile nobody has touched, so a row // that differs from it can be marked. See [settingDefaults]. defaults map[string]string - // routing is the routing row this session was launched under ([app.routing]), - // taken once for the same reason [sheet.sessionModel] is: it is what the - // SESSION is routing by, the row itself lands on the next session, and a - // panel that re-read it off the disk would explain the `lane` row by a word - // nothing is acting on yet. + // routing is the routing row in force ([app.routing]), taken when the panel + // opens for the same reason [sheet.sessionModel] is — a panel that re-read + // the disk on every frame would be a list that moved while somebody looked + // at it — and REWRITTEN the moment this panel is the thing that changed it + // (lanes.go's [app.routingRowChanged]). The row lands on the next message, + // so the sentence explaining the `lane` row under it may not go on + // describing the word that was there a keystroke ago. routing string // sessionModel is the model this conversation is on — the FLOOR of every // role's ladder ([roles.Resolve]), and therefore what a role row resolves to @@ -2185,6 +2187,8 @@ func (a *app) applySetting(item sheetItem, raw string) { a.laneRowChanged() case config.KeyLaneGuard: provider.SetLaneGuard(config.LaneGuardAt(a.profileDir)) + case config.KeyRouting: + a.routingRowChanged() } a.sheet.msg = "" a.sheet.rows = a.sheet.registry.Rows() @@ -2918,20 +2922,8 @@ func (s *sheet) laneWord(item sheetItem) string { // second time: the two rows are two readings of one fact, and a panel where // they could disagree would be a panel that is wrong about one of them. if row, ok := s.registry.Row(config.LaneSettingKey(talkSlot)); ok { - if word := row.Value(); word != "" && !strings.EqualFold(word, config.LaneAuto) { - word = strings.ToLower(word) - // AND THE ROW SAYS SO WHEN THE CHOICE IS NOT REACHING THE WIRE - // (issue #433). A base that has answered that it will not carry a - // routing preference — a proxy, a mirror, a plain endpoint — leaves - // `pinned: cloudflare` standing on the screen as a claim about a - // request that did not carry it, which is the silent substitution - // this build forbids. The conversation is told once - // ([provider.UncarriedPinLine]); this row keeps saying it, because - // it is the row somebody comes back to look at. - if !provider.BaseTakesLaneChoice() { - word += " (not taken on this base)" - } - return word + if tail := laneRowTail(row.Value(), laneInForce(s.sessionModel)); tail != "" { + return tail } } // AND NAMING THE MACHINE `auto` WOULD USE IS A CLAIM ONLY A CHOOSER CAN @@ -2949,6 +2941,44 @@ func (s *sheet) laneWord(item sheetItem) string { return "" } +// laneRowTail is the model row's machine tail composed from the two facts that +// decide it: the `lane` row as a person wrote it, and what the wire will do with +// it ([laneInForce]). Empty means the row names no machine, and the caller falls +// through to what `auto` may claim. +// +// IT IS A FUNCTION OF ITS ARGUMENTS so that every state it has can be read back +// in one place, and because the three of them are one decision: whether this row +// may name a machine at all. +// +// THE ROW SAYS SO WHEN THE CHOICE IS NOT REACHING THE WIRE (issue #433). A base +// that has answered that it will not carry a routing preference — a proxy, a +// mirror, a plain endpoint — leaves `pinned: cloudflare` standing on the screen +// as a claim about a request that did not carry it, which is the silent +// substitution this build forbids. The conversation is told once +// ([provider.UncarriedPinLine]); this row keeps saying it, because it is the row +// somebody comes back to look at. +// +// AND A PAIRING THE WIRE HAS RETIRED IS THE SAME SUBSTITUTION ONE STEP LATER +// (issue #1022). The row on disk still names morph, every request since the +// refusal has gone out on auto, and a tail reading `pinned: morph` over three +// such turns is this row claiming a machine nothing asked for. So it says where +// the requests really go and names whose pin came off, in the sentence's own +// spelling ([provider.RetiredPinTail]) — while the `lane` row itself is left +// exactly as the person wrote it, because pinning again puts it straight back. +func laneRowTail(row string, force laneForce) string { + word := strings.ToLower(strings.TrimSpace(row)) + if word == "" || word == config.LaneAuto { + return "" + } + if stood := force.standDown; stood != "" { + return config.LaneAuto + " " + provider.RetiredPinTail(strings.ToLower(stood)) + } + if !provider.BaseTakesLaneChoice() { + return word + " (not taken on this base)" + } + return word +} + // connBoxRows is the most an open box may take from the list window. Its row // heading and the line of air below it are spoken for first; a short window // keeps the six-row ceiling that preserves the beginning of the question. From afc33e75fee9d32be49c82486c838dc911c6163f Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sun, 13 Sep 2026 22:16:47 -0400 Subject: [PATCH 02/11] docs: the change entry for #1025 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_0182eyxXP8Xe7ADNKpYDe4wv --- .../unreleased/1025-pin-chip-live-routing.md | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 docs/changes/unreleased/1025-pin-chip-live-routing.md diff --git a/docs/changes/unreleased/1025-pin-chip-live-routing.md b/docs/changes/unreleased/1025-pin-chip-live-routing.md new file mode 100644 index 0000000000..0312c8d701 --- /dev/null +++ b/docs/changes/unreleased/1025-pin-chip-live-routing.md @@ -0,0 +1,20 @@ +--- +kind: fixed +title: the chip names a machine only while the wire would demand it, and a routing change lands now +pr: 1025 +surface: [chat, engine, docs] +invalidates: + - "The chip on the model word, the settings panel's `lane` tail, the picker fold's mark and a picker row's `via` were drawn from the PROFILE ROW (`config.LanePinned` / `config.LaneAt`) while the wire asked the transport (`provider.PinnedFor`). The two disagree the moment the wire retires a pairing, and on 2026-09-13 that left `moonshotai/kimi-k3@morph` on the screen over three turns another machine answered (#1022). Every one of them now reads one door — `provider.PinNow`, which answers the machine a request will demand AND the machine a person's row still names that nothing is asking for — so a retired pairing shows `auto`, the model row's tail reads `auto (morph cannot serve this model)`, and the fold marks `auto`. The `lane` row itself is untouched and still reads `pinned: morph`, because pinning again puts it straight back." + - "`app.pinnedNow` keyed `provider.PinnedFor` on the surface's own spelling of the model while retirements are keyed on the ledger's. The question now goes through the door that folds (`lanePinFor`), so there is no key built on the surface side to disagree with." + - "A `routing` change landed on the NEXT SESSION. `internal/manual/chat/models-and-cost.md` said so in those words. It lands on your next message: the chat door no longer writes the row onto `session.Config.Routing` — a handed-down answer beats the row `provider.InstallRouting` puts in place, which is why the conversation's clients stayed pinned to the word that was on disk at launch — it installs the row and hands down nothing, and the settings panel re-installs it (`config.InstallRoutingRow`) and re-reads `app.routing` and `sheet.routing` in the same keystroke. An engine host and a task child still carry their parent's row explicitly and still win." + - "`session.Config.Routing` was filled in by every local launch. It is now EMPTY for a launch from a profile, and empty means the session reads the live installed row. Anything on this side that gated on the field — the lane beat's `routing: off` check — asks `Config.routingInForce()`, which is the same rule the adapter applies (`Client.routingChoice`)." + - "`AFORGE_DEBUG=1 aforge` wrote a run folder holding `run.json` and no request bodies at all, while `aforge chat --debug` wrote them: the host rule read the FLAG (`debug: *debug`) and the record has three doors, so the environment pin took the session-host road and the host — never told to record — made the calls. It reads the switch (`trace.Enabled()`), so a recording launch keeps its conversation in this terminal whichever way it was asked for, exactly as `--no-host` does." + - "The lane fold had no gesture for going back to `auto` — it was a walk up past every machine in the list, with another model's row, where `enter` switches the model, one press further. `enter` on the machine already in force now takes the pin off, and the hint slot reads `enter unpin · ← back · esc` while the cursor is on it. On a machine the wire has stood down, `enter` still PINS: that is a person saying try again, which is the sentence the retirement makes to them. The `auto` row is unchanged and still the explicit way to say it." + - "Typing in the filter box while a fold was open filtered MODELS and closed the fold — typing `morph` inside a fold matched `morph/morph-v3-large` in the catalog and shut the machines the person was reading. While a fold is open the box matches that model's machines first, through the same matcher the model list uses, and falls through to the models only when none of them match." +--- + +The two halves of item one are one defect: a surface may name a machine only +while a request would demand it, and it may only ask that question through the +door that folds the model's name the way the transport does. Everything else +here is the same shape one layer out — a row a person watched themselves change +that then did nothing to the very next request. From a363b3d18c6930093f8b37ee39dbed58be0bba46 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sun, 13 Sep 2026 22:18:39 -0400 Subject: [PATCH 03/11] config: the routing row names the installer as its reader The row is installed on the process-wide knob rather than handed down onto the session's config, so the accessor the v3 door used to touch is no longer called from outside this package. The law that every persisted row names a reader something outside internal/config really calls now names InstallRoutingRow, which is what the launch and the settings panel both call. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_0182eyxXP8Xe7ADNKpYDe4wv --- internal/config/derivation_test.go | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/internal/config/derivation_test.go b/internal/config/derivation_test.go index b043dacc5d..f1d405ff40 100644 --- a/internal/config/derivation_test.go +++ b/internal/config/derivation_test.go @@ -175,15 +175,17 @@ var settingReaders = map[string]string{ // turn end, beside the mouse and the gate posture — and becomes what the // transcript draws of the clock (internal/tui3's timestamps.go). KeyTimestamps: "TimestampsAt", - // The routing row is read by the v3 door and becomes session.Config.Routing, - // which the adapter turns into the preference object on every request - // (internal/provider's velocity.go). It names the accessor the door touches, - // which is the CHOICE reader rather than the resolved one: the door has to be - // able to hand down "nobody wrote a word", because that is what lets a client - // handed nothing fall to the row this process installed while an actual word - // still wins. RoutingAt is the settings sheet's read of the same row and - // stays what it always was. - KeyRouting: "RoutingChoiceAt", + // The routing row is INSTALLED rather than handed down — `InstallRoutingRow` + // puts it on the process-wide knob every client that was handed nothing + // reads (internal/provider's velocity.go), and the v3 door deliberately + // hands the session no answer of its own so that a row written mid-session + // reaches the very next request (#1022). So the reader named here is the + // installer, which is what is really called from outside this package — by + // the launch, and again by the settings panel on the keystroke that writes + // the row. `RoutingChoiceAt` is the choice reader it is built on and + // `RoutingAt` is the surface's resolved read of the same row; both stay what + // they were. + KeyRouting: "InstallRoutingRow", // The fallback chain is read by the v3 door and becomes // session.Config.ModelFallbacks, which the adapter walks when no endpoint // serving the session's model will accept the request at all From 25f616e49d1f9379029d7265cc351eba1e7c9c1a Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sun, 13 Sep 2026 22:20:51 -0400 Subject: [PATCH 04/11] the comments say only what this build really does about a carried routing row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No door in this binary hands a routing row down any more: the engine host builds its session through the same profile door, and the two in-process auxiliaries carry a parent field that is now empty. The seam is still there and a caller that holds a row this process did not install still wins with it — which is what the comments now say, instead of naming call sites that do not exist. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_0182eyxXP8Xe7ADNKpYDe4wv --- cmd/aforge/chatv3.go | 6 +++--- docs/changes/unreleased/1025-pin-chip-live-routing.md | 2 +- internal/session/session.go | 6 +++--- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/cmd/aforge/chatv3.go b/cmd/aforge/chatv3.go index 05fb4908a0..0bf9c4cabc 100644 --- a/cmd/aforge/chatv3.go +++ b/cmd/aforge/chatv3.go @@ -1442,9 +1442,9 @@ func applyV3Governance(cfg session.Config, profileDir string, yolo, oneModel boo // HERE, and that absence is the point. This door used to read the routing row // and write it onto the config, which the session then handed to every client // it built as an answer of its own ([provider.StaticRouting]) — and an answer - // handed down WINS over the row this process installs, for the good reason - // that an engine host and a task child carry their parent's row explicitly. - // So the conversation's clients were pinned to the word that was on disk at + // handed down WINS over the row this process installs, which is what a caller + // holding a row this process did not install needs it to do. So the + // conversation's clients were pinned to the word that was on disk at // launch: a person who cycled `routing` in the settings panel watched the row // change, watched the `lane` row go on explaining itself in the old word, and // got the old routing on every request until they relaunched (issue #1022). diff --git a/docs/changes/unreleased/1025-pin-chip-live-routing.md b/docs/changes/unreleased/1025-pin-chip-live-routing.md index 0312c8d701..0fc88fea6d 100644 --- a/docs/changes/unreleased/1025-pin-chip-live-routing.md +++ b/docs/changes/unreleased/1025-pin-chip-live-routing.md @@ -6,7 +6,7 @@ surface: [chat, engine, docs] invalidates: - "The chip on the model word, the settings panel's `lane` tail, the picker fold's mark and a picker row's `via` were drawn from the PROFILE ROW (`config.LanePinned` / `config.LaneAt`) while the wire asked the transport (`provider.PinnedFor`). The two disagree the moment the wire retires a pairing, and on 2026-09-13 that left `moonshotai/kimi-k3@morph` on the screen over three turns another machine answered (#1022). Every one of them now reads one door — `provider.PinNow`, which answers the machine a request will demand AND the machine a person's row still names that nothing is asking for — so a retired pairing shows `auto`, the model row's tail reads `auto (morph cannot serve this model)`, and the fold marks `auto`. The `lane` row itself is untouched and still reads `pinned: morph`, because pinning again puts it straight back." - "`app.pinnedNow` keyed `provider.PinnedFor` on the surface's own spelling of the model while retirements are keyed on the ledger's. The question now goes through the door that folds (`lanePinFor`), so there is no key built on the surface side to disagree with." - - "A `routing` change landed on the NEXT SESSION. `internal/manual/chat/models-and-cost.md` said so in those words. It lands on your next message: the chat door no longer writes the row onto `session.Config.Routing` — a handed-down answer beats the row `provider.InstallRouting` puts in place, which is why the conversation's clients stayed pinned to the word that was on disk at launch — it installs the row and hands down nothing, and the settings panel re-installs it (`config.InstallRoutingRow`) and re-reads `app.routing` and `sheet.routing` in the same keystroke. An engine host and a task child still carry their parent's row explicitly and still win." + - "A `routing` change landed on the NEXT SESSION. `internal/manual/chat/models-and-cost.md` said so in those words. It lands on your next message: the chat door no longer writes the row onto `session.Config.Routing` — a handed-down answer beats the row `provider.InstallRouting` puts in place, which is why the conversation's clients stayed pinned to the word that was on disk at launch — it installs the row and hands down nothing, and the settings panel re-installs it (`config.InstallRoutingRow`) and re-reads `app.routing` and `sheet.routing` in the same keystroke. The field stays the way a caller HANDS a row down — a child built from a parent's own config rather than from a profile — and such a caller still wins over the installed row." - "`session.Config.Routing` was filled in by every local launch. It is now EMPTY for a launch from a profile, and empty means the session reads the live installed row. Anything on this side that gated on the field — the lane beat's `routing: off` check — asks `Config.routingInForce()`, which is the same rule the adapter applies (`Client.routingChoice`)." - "`AFORGE_DEBUG=1 aforge` wrote a run folder holding `run.json` and no request bodies at all, while `aforge chat --debug` wrote them: the host rule read the FLAG (`debug: *debug`) and the record has three doors, so the environment pin took the session-host road and the host — never told to record — made the calls. It reads the switch (`trace.Enabled()`), so a recording launch keeps its conversation in this terminal whichever way it was asked for, exactly as `--no-host` does." - "The lane fold had no gesture for going back to `auto` — it was a walk up past every machine in the list, with another model's row, where `enter` switches the model, one press further. `enter` on the machine already in force now takes the pin off, and the hint slot reads `enter unpin · ← back · esc` while the cursor is on it. On a machine the wire has stood down, `enter` still PINS: that is a person saying try again, which is the sentence the retirement makes to them. The `auto` row is unchanged and still the explicit way to say it." diff --git a/internal/session/session.go b/internal/session/session.go index d2fa682158..1ac9f97700 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -1120,9 +1120,9 @@ type Config struct { // profile's row is installed process-wide instead (internal/config's // InstallLaneRows), so a person who cycles `routing` in the settings panel // is answered by the very next request rather than by the next launch - // (issue #1022). What still fills this field is a caller carrying a row that - // is not this process's to read — an engine host, a task child — and such a - // caller still wins over the installed row. + // (issue #1022). The field is still the way a caller HANDS a row down — a + // child built from a parent's own config rather than from a profile — and + // such a caller still wins over the installed row. Routing provider.RoutingStrategy // CompactEnabled gates automatic compaction. Manual compaction via the From c032ad99a851c9c4d24390828a122f96c0fffdcb Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sun, 13 Sep 2026 22:24:04 -0400 Subject: [PATCH 05/11] tui3: the panel asks the surface what the wire will do with the lane row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tail on the model row was asking the transport directly, which skips the four states where this surface may name no machine at all — a hosted window, routing off, no model, a model served direct. It now goes through the same door the chip does, as a door rather than a snapshot, because a pin written two rows above it moves the answer in the same keystroke. The one reading that had no caller is gone. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_0182eyxXP8Xe7ADNKpYDe4wv --- internal/tui3/lanes.go | 8 -------- internal/tui3/settings.go | 23 ++++++++++++++++++++++- 2 files changed, 22 insertions(+), 9 deletions(-) diff --git a/internal/tui3/lanes.go b/internal/tui3/lanes.go index 42e79e0908..2a83ef11c8 100644 --- a/internal/tui3/lanes.go +++ b/internal/tui3/lanes.go @@ -1325,14 +1325,6 @@ func (a *app) pinnedNow() string { return strings.ToLower(a.laneForceNow().name) } -// laneStoodDown is the machine the `lane` row still names while no request asks -// for it, lowercased like every other lane name here and empty while the row and -// the wire agree. It is the half of [laneInForce] a surface needs in order to -// say `auto` and still say whose name came off. -func (a *app) laneStoodDown() string { - return strings.ToLower(a.laneForceNow().standDown) -} - // laneForceNow is [laneInForce] for the model this conversation is on, with the // four states where this surface may name no machine at all answered first: a // session over a connection (the pin is the far machine's and this process diff --git a/internal/tui3/settings.go b/internal/tui3/settings.go index 817381f850..866820a7b3 100644 --- a/internal/tui3/settings.go +++ b/internal/tui3/settings.go @@ -898,6 +898,16 @@ type sheet struct { conns Connections modelRows func() []connect.Status sources modelsource.Set + // force is what the wire will do with the `lane` row, asked of the surface + // that owns the four states where no machine may be named at all — a hosted + // window, routing `off`, no model, a model served direct (lanes.go's + // [app.laneForceNow]). + // + // IT IS A DOOR AND NOT A SNAPSHOT, which is the one field here that is. The + // answer moves inside this panel — a pin written on the row two lines up + // changes it in the same keystroke — so a value copied when the panel opened + // would be the tail describing the pin before the one a person just set. + force func() laneForce // conn is what that tab remembers between builds (connectcaps.go). conn connTab rows []config.Setting @@ -1204,6 +1214,7 @@ func (a *app) raiseSettings() { conns: a.conns, modelRows: a.modelConnectionRows, sources: a.sources, + force: a.laneForceNow, defaults: settingDefaults(), sessionModel: a.model, routing: a.routing, @@ -2922,7 +2933,7 @@ func (s *sheet) laneWord(item sheetItem) string { // second time: the two rows are two readings of one fact, and a panel where // they could disagree would be a panel that is wrong about one of them. if row, ok := s.registry.Row(config.LaneSettingKey(talkSlot)); ok { - if tail := laneRowTail(row.Value(), laneInForce(s.sessionModel)); tail != "" { + if tail := laneRowTail(row.Value(), s.laneForce()); tail != "" { return tail } } @@ -2941,6 +2952,16 @@ func (s *sheet) laneWord(item sheetItem) string { return "" } +// laneForce is what the wire will do with the lane row, and the empty answer for +// a panel nobody handed the door to — a test's bare sheet, which has no surface +// behind it to ask. +func (s *sheet) laneForce() laneForce { + if s.force == nil { + return laneForce{} + } + return s.force() +} + // laneRowTail is the model row's machine tail composed from the two facts that // decide it: the `lane` row as a person wrote it, and what the wire will do with // it ([laneInForce]). Empty means the row names no machine, and the caller falls From ab46af8c4433ab31baca41f4f701c34c62851367 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sun, 13 Sep 2026 22:25:47 -0400 Subject: [PATCH 06/11] tui3: a blank line between the two paragraphs over applyLaneChoice Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_0182eyxXP8Xe7ADNKpYDe4wv --- internal/tui3/lanes.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/tui3/lanes.go b/internal/tui3/lanes.go index 2a83ef11c8..746c45589a 100644 --- a/internal/tui3/lanes.go +++ b/internal/tui3/lanes.go @@ -692,6 +692,7 @@ func (a *app) routingOff() bool { return a.routing == config.RoutingOff } // (palette.go's [app.pickerKey]) and the settings panel's model and lane rows // (settings.go's [app.sheetSelectKey]) — and a pin written two ways is a pin // that drifts the first time one of the two is fixed. +// // ENTER ON THE MACHINE ALREADY IN FORCE TAKES THE PIN OFF. It is a toggle on // the same key that put the pin on, which is the only way back to `auto` a // person can find without being told: the `auto` row sits above every machine in From a0e4fd56b48d3ef3e27b4fe6c89186be00775308 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sun, 13 Sep 2026 22:27:25 -0400 Subject: [PATCH 07/11] tui3: the sheet's door field says what it is next to, accurately Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_0182eyxXP8Xe7ADNKpYDe4wv --- internal/tui3/settings.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/internal/tui3/settings.go b/internal/tui3/settings.go index 866820a7b3..572864fe67 100644 --- a/internal/tui3/settings.go +++ b/internal/tui3/settings.go @@ -903,10 +903,10 @@ type sheet struct { // window, routing `off`, no model, a model served direct (lanes.go's // [app.laneForceNow]). // - // IT IS A DOOR AND NOT A SNAPSHOT, which is the one field here that is. The - // answer moves inside this panel — a pin written on the row two lines up - // changes it in the same keystroke — so a value copied when the panel opened - // would be the tail describing the pin before the one a person just set. + // IT IS A DOOR AND NOT A SNAPSHOT, unlike the two fields under it. The answer + // moves inside this panel — a pin written on the row two lines up changes it + // in the same keystroke — so a value copied when the panel opened would be + // the tail describing the pin before the one a person had just set. force func() laneForce // conn is what that tab remembers between builds (connectcaps.go). conn connTab From e97c81bd17862d1ef809e8fe71b1c3bbf447e417 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sun, 13 Sep 2026 22:28:54 -0400 Subject: [PATCH 08/11] session: the new reader stands above newProviderClient's own doc comment It had been inserted between that comment and the function it documents. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_0182eyxXP8Xe7ADNKpYDe4wv --- internal/session/clientdoor.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/session/clientdoor.go b/internal/session/clientdoor.go index 8a5a597a95..f3128477f9 100644 --- a/internal/session/clientdoor.go +++ b/internal/session/clientdoor.go @@ -186,9 +186,6 @@ func accountForService(service modelsource.Connected) modelAccount { } } -// newProviderClient is construction shared by New and a live cross-service -// switch. The same routing and fallback seams must survive replacement; a -// second, smaller constructor would silently change how the next turn runs. // routingInForce is the row THIS SESSION'S calls answer to: its own where a // caller handed one down, and the row installed in this process otherwise. // @@ -204,6 +201,9 @@ func (c Config) routingInForce() provider.RoutingStrategy { return provider.RoutingNow() } +// newProviderClient is construction shared by New and a live cross-service +// switch. The same routing and fallback seams must survive replacement; a +// second, smaller constructor would silently change how the next turn runs. func newProviderClient(config Config, model string) (*provider.Client, error) { settings := config.clientConfig(model, providerTimeout) // AN EMPTY ROW HERE IS NOBODY HAVING CHOSEN, and that is what a launch from From 0919fab75ddc8418df36163eb3788ec06419d2d7 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sun, 13 Sep 2026 22:29:35 -0400 Subject: [PATCH 09/11] cmd/aforge: the test's comment does not name a call site that does not exist Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_0182eyxXP8Xe7ADNKpYDe4wv --- cmd/aforge/routinglive_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd/aforge/routinglive_test.go b/cmd/aforge/routinglive_test.go index 84bb85ad3b..0e9a019976 100644 --- a/cmd/aforge/routinglive_test.go +++ b/cmd/aforge/routinglive_test.go @@ -13,8 +13,8 @@ import ( // THE MEASURED FAILURE (issue #1022). This door read the routing row and wrote // it onto the session's config, which the session handed to every client it // built as an answer OF ITS OWN — and an answer handed down beats the row this -// process installs, because an engine host and a task child carry their parent's -// row that way on purpose. So the conversation's clients were pinned to the word +// process installs, which is what a caller holding a row this process did not +// install needs it to do. So the conversation's clients were pinned to the word // that was on disk at launch: somebody cycled `routing` in the settings panel, // watched the row change, and went on being routed by the old word until they // relaunched. From 32f7b681d85f8b9564fc189d9042ab4128a0128d Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sun, 13 Sep 2026 22:40:33 -0400 Subject: [PATCH 10/11] tui3: the fold's filter ranks its machines the way the list ranks models MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found by the Spark gate: `core` matches Cloudflare too, because c-l-o-u-d-f-l-a-r-e carries those four letters in order and the third matching rung is a subsequence. Keeping them in the ledger's order put the cursor on the machine nobody typed for, so the matched machines are sorted by the same summed rungs a model id is scored by — a prefix first, then a substring by where it starts, then the loose letters — with ties keeping the order the fold draws when nothing is typed. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_0182eyxXP8Xe7ADNKpYDe4wv --- internal/tui3/palette.go | 40 ++++++++++++++++++++++++++--------- internal/tui3/pinchip_test.go | 29 ++++++++++++++++++++++--- 2 files changed, 56 insertions(+), 13 deletions(-) diff --git a/internal/tui3/palette.go b/internal/tui3/palette.go index b9d3b81f62..023d9c23a8 100644 --- a/internal/tui3/palette.go +++ b/internal/tui3/palette.go @@ -386,15 +386,32 @@ func (p *picker) narrowFold(model string, tokens []string, terms []laneTerm, now return false } views := laneViews(model, now) - kept := make([]laneView, 0, len(views)) + matched := make([]struct { + view laneView + score int + }, 0, len(views)) for _, view := range views { - if matchesEvery(strings.ToLower(view.Name), tokens) { - kept = append(kept, view) + if total, hit := queryScore(strings.ToLower(view.Name), tokens); hit { + matched = append(matched, struct { + view laneView + score int + }{view, total}) } } - if len(kept) == 0 { + if len(matched) == 0 { return false } + // THE MACHINES ARE RANKED THE WAY THE MODELS ARE, by the same rungs: a + // prefix first, then a substring by where it starts, then the loose letters + // last. `core` matches Cloudflare too — c-l-o-u-d-f-l-a-r-e carries the four + // letters in order — and a list that left it on top would put the cursor on + // the machine nobody typed for. Ties keep the ledger's own order, which is + // the order the fold draws when nothing is typed. + sort.SliceStable(matched, func(a, b int) bool { return matched[a].score < matched[b].score }) + kept := make([]laneView, 0, len(matched)) + for _, one := range matched { + kept = append(kept, one.view) + } // The fold's own model is the only hit, so the machines that matched are // drawn under the name they serve and nothing else is on the screen to // wonder about. The `auto` row's prediction is taken over ALL the views: it @@ -417,15 +434,18 @@ func (p *picker) narrowFold(model string, tokens []string, terms []laneTerm, now return true } -// matchesEvery is [tokenScore]'s yes-or-no half over a whole query: every token -// has to match, which is the same ANDing the model list does. -func matchesEvery(text string, tokens []string) bool { +// queryScore is [tokenScore] over a whole query: every token has to match and +// the rungs are summed, which is exactly what [picker.rank] does to a model id. +func queryScore(text string, tokens []string) (int, bool) { + total := 0 for _, token := range tokens { - if _, hit := tokenScore(text, token); !hit { - return false + score, hit := tokenScore(text, token) + if !hit { + return 0, false } + total += score } - return true + return total, true } // splitQuery divides what is typed into the words that rank and the terms that diff --git a/internal/tui3/pinchip_test.go b/internal/tui3/pinchip_test.go index e418f99582..0122df58a7 100644 --- a/internal/tui3/pinchip_test.go +++ b/internal/tui3/pinchip_test.go @@ -142,18 +142,31 @@ func TestTypingInsideAnOpenFoldFiltersTheMachines(t *testing.T) { if a.pick.unfold != flash { t.Fatalf("typing inside the fold closed it (fold %q)", a.pick.unfold) } - if len(a.pick.lanes) != 1 || !strings.EqualFold(a.pick.lanes[0].Name, "coreweave") { - t.Fatalf("the fold holds %d machines, want coreweave alone: %+v", len(a.pick.lanes), a.pick.lanes) + // THE RUNGS ARE THE MODEL LIST'S OWN, so the machine that CARRIES the word + // sorts above the one whose letters merely appear in order — `core` is a + // subsequence of `cloudflare` — and deepinfra, which carries no `c` at all, + // is not here. + if names := laneNames(a.pick.lanes); len(names) != 2 || names[0] != "coreweave" || names[1] != "cloudflare" { + t.Fatalf("the fold holds %v, want coreweave then cloudflare", names) } row, on := a.pick.laneUnder() if !on || row.lane != 0 { - t.Fatalf("the cursor is not on the machine that matched: %+v (on=%v)", row, on) + t.Fatalf("the cursor is not on the machine that matched best: %+v (on=%v)", row, on) } drive(t, a, key("enter")) if got := a.pinnedNow(); !strings.EqualFold(got, "coreweave") { t.Fatalf("enter on the matched machine pinned %q", got) } + // AND A QUERY ONLY ONE MACHINE ANSWERS LEAVES ONLY THAT ONE. + typeLine(t, a, "/model") + drive(t, a, key("right")) + typeInto(t, a, "corew") + if names := laneNames(a.pick.lanes); len(names) != 1 || names[0] != "coreweave" { + t.Fatalf("the fold holds %v, want coreweave alone", names) + } + drive(t, a, key("esc")) + // AND A QUERY NO MACHINE MATCHES STILL FILTERS THE MODELS, closing the fold // with it — the fall-through that keeps one filter box doing one thing. typeLine(t, a, "/model") @@ -167,6 +180,16 @@ func TestTypingInsideAnOpenFoldFiltersTheMachines(t *testing.T) { } } +// laneNames is a fold's machines, lowercased, for a test that is about which of +// them survived a filter and in what order. +func laneNames(views []laneView) []string { + out := make([]string, 0, len(views)) + for _, view := range views { + out = append(out, strings.ToLower(view.Name)) + } + return out +} + // ── A ROUTING CHANGE LANDS ON THE NEXT MESSAGE ────────────────────────────── // // The row went to disk and nowhere else: the surface's reading of it and the From 1087d9717ab856adb2c25265dade8851af3b32f7 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sun, 13 Sep 2026 22:41:14 -0400 Subject: [PATCH 11/11] tui3: one scorer for the filter box, whether it is ranking models or machines The model list's own loop and the fold's were the same summed rungs written twice; the list now asks queryScore as the fold does, and the fold's two parallel readings ride a named pair instead of an anonymous one. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_0182eyxXP8Xe7ADNKpYDe4wv --- internal/tui3/palette.go | 29 +++++++++++------------------ 1 file changed, 11 insertions(+), 18 deletions(-) diff --git a/internal/tui3/palette.go b/internal/tui3/palette.go index 023d9c23a8..58b00e1c7f 100644 --- a/internal/tui3/palette.go +++ b/internal/tui3/palette.go @@ -311,16 +311,8 @@ func (p *picker) rank() { p.hits = append(p.hits, i) continue } - total, matched := 0, true - for _, token := range tokens { - score, hit := tokenScore(id, token) - if !hit { - matched = false - break - } - total += score - } - if !matched { + total, hit := queryScore(id, tokens) + if !hit { continue } p.score[i] = total @@ -386,16 +378,10 @@ func (p *picker) narrowFold(model string, tokens []string, terms []laneTerm, now return false } views := laneViews(model, now) - matched := make([]struct { - view laneView - score int - }, 0, len(views)) + matched := make([]scoredLane, 0, len(views)) for _, view := range views { if total, hit := queryScore(strings.ToLower(view.Name), tokens); hit { - matched = append(matched, struct { - view laneView - score int - }{view, total}) + matched = append(matched, scoredLane{view: view, score: total}) } } if len(matched) == 0 { @@ -434,6 +420,13 @@ func (p *picker) narrowFold(model string, tokens []string, terms []laneTerm, now return true } +// scoredLane is one machine of an open fold beside how well the filter box +// matched its name, so the two can be sorted together. +type scoredLane struct { + view laneView + score int +} + // queryScore is [tokenScore] over a whole query: every token has to match and // the rungs are summed, which is exactly what [picker.rank] does to a model id. func queryScore(text string, tokens []string) (int, bool) {