Skip to content

Every agent reports its learned default model once, when a chat starts - #26982

Merged
brennanb2025 merged 6 commits into
mainfrom
brennanb2025/learned-default-once
Oct 9, 2026
Merged

brennanb2025 merged 6 commits into
mainfrom
brennanb2025/learned-default-once

Conversation

@brennanb2025

@brennanb2025 brennanb2025 commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

ELI5

Orca learns which model each agent runs by default from a chat you start without picking one, and then names that model on the next new chat's first frame (#26407, extended to Pi in #26821). Two reviews of #26821 found that a chat left open keeps re-teaching the model it started on. So after your agent's default changes, every new chat names the old model and then jumps to the right one, for as long as that old chat stays open. They also found that Grok, OpenCode and OMP used different timing from Pi for the same rule (Claude had the same per-read timing as Grok), plus a few small gaps in Pi's project-config check. This PR makes every agent that learns a default (Claude, Grok, OpenCode, OMP and Pi) report it exactly once, when the chat starts, and fixes the small gaps. Codex learns nothing from chats: its background listing reads its config directly.

What Changed

The problem

  • Orca saves an agent's "configured default" from a new chat started with no model pick. That report rode on every options read, which happens on every turn and whenever the chat pane shows.
  • Grok, OpenCode and OMP re-derived it from the session's current model on each read. Claude re-reported what its settings applied when it started. Pi (since A new Pi chat names the model it will run on its first frame #26821) re-sent the model it started on. Either way, a chat left open kept writing back an answer that could be out of date.
  • Example: chat A starts on model Y and teaches Y. You change the agent's own default to Z (for example with Pi's /model, or Claude's settings, in a terminal). Chat B starts on Z and teaches Z. Then A's next turn writes Y back. Every new chat now opens on "Y" and jumps to "Z", until A is closed.

What you see now

  • Once a chat started with no pick has taught the default, that chat never changes it again. A later chat's newer answer stands, so new chats name the model they will actually run.
  • A model you pick mid-chat, or one the agent or an extension switches to, is never learned (unchanged for Pi since A new Pi chat names the model it will run on its first frame #26821; now the same for Grok, OpenCode and OMP).
  • Pi: an empty .pi/settings.json, or a .pi/extensions folder holding only dotfiles (.DS_Store, .gitkeep), no longer keeps the "Model" placeholder. Pi reads both as nothing.
  • The thinking-level fix from A new Pi chat names the model it will run on its first frame #26821 (a learned xhigh survives Pi's coarser background listing) can no longer revive an effort that the agent's own, newer menu has dropped.

Mechanism

  • One report, at the start. The step that starts an agent's child (its acquisition) now carries a catalogListing: the models it started with, plus the shared no-pick rule's answer (unpickedSessionConfiguredChoice). That answer is taken once, after the chat's saved picks are restored.
    • The host saves it once, through the same save step and workspace check as before, as soon as the new child's ownership is proven. The hook is onStartCatalogListing in the attach flow.
    • Claude starts differently: its child is published before its handshake, proves its start with a started event, and only learns what its settings resolved from a settings readback right after. That readback's options-reported event now carries the start listing with the resolved default. The host saves it once, for the child that reported it.
    • Options reads now carry only the model list, never a default.
    • The host saves Claude's readback in the session's serialized step, behind the attach that published the child (as it already does for started), so a readback that arrives before the attach finishes indexing the child is still saved. Saving a start listing is bookkeeping: if it throws, the failure is logged and the start goes ahead.
    • Grok, OpenCode, OMP and Pi share one path and one rule (unpickedSessionConfiguredChoice). Claude shares the timing (one report per start, never on reads) but keeps its own equivalent test of what was picked (configuredClaudeDefault: no launch model, no model pick, and the model its settings readback says it applied).
    • Why a start snapshot is complete for the agents that take one. Each answers session/new with its full model list and current model, built synchronously, and sends config_option_update only after a model or thinking change. Checked by reading the installed packages, without running them: OMP 18.4's newSession builds the list from the session's models, and pushes updates only on model_changed/thinking_level_changed. OpenCode 2.0.14's newSession returns the options after creating the session with the configured model, and never sends the update. OpenCode 1.18.31 sends it only after a model pick. Grok's captured answer carries its options. An OMP that lists no models (the captured Windows no-model case) reports no model, so nothing is learned, as before.
  • Chats created with their first message (Save a new chat's first message during create #26332) learn the same way. Their agent starts when the message is delivered, and that delivery goes through the same acquisition, so the start report fires there too. Save a new chat's first message during create #26332's Grok test now also checks that the default is learned before any options read.
  • Effort menu: when the newer listing's menu lacks the learned effort, the saved list falls back to the older menu only if the older one is the chat's own listing, never an older background listing.
  • Pi config:
    • An empty or BOM-only settings file is no settings.
    • A dotfile-only extensions folder is no extensions.
    • An extensions folder that can't be read still counts as possibly picking a model. Pi itself loads nothing from it, so this only keeps the placeholder, never shows a wrong model.
    • The BOM strip is written as the escape \uFEFF instead of a raw invisible character.
  • Housekeeping: the three "acquisition ended" error classes moved from structured-agent-session-adapter.ts into structured-agent-session-acquisition-errors.ts (re-exported, no import changes). That keeps the adapter types file under its line limit with the new field.
  • Nothing changes on the wire or on the phone.

Why

The owner asked for one rule for every agent. The common pattern derives what a new chat shows from facts the host already holds, and a fact about how an agent's config resolved belongs to the moment it resolved: the start. Reporting once at the start removes the stale write-back for every agent at the root. The alternative, having each read hand its snapshot over once and then clear it, would keep the default tied to whenever the pane happens to read. It would also miss chats whose options are never read.

Differences from the common pattern

  • Intended: every agent learns only from what a chat started on, reported once. The cost is that a chat's own mid-chat model change never updates the default; the next new chat with no pick does.
  • Intended: Claude reports at its settings readback rather than at the start step itself. That readback is the first moment Claude's resolved default is known, and it happens once per start. Claude also keeps its own pick test (configuredClaudeDefault) instead of the shared function; the two agree on every case: a launch or saved model pick means nothing is learned, and an effort pick means no effort is learned.
  • Intended: Codex has no learned default. Its background listing asks Codex for its configured model (config/read), so a chat has nothing to teach.
  • Temporary (unchanged from A new Pi chat names the model it will run on its first frame #26821): Pi's project trust isn't modelled, and a worktree the app hasn't read this run shows "Model" until the host answers its workspace check.

Linked Issue

Follows up #26821 (its two review rounds) and #26407. No separate issue.

Visual Proof

Captured on Windows in a hidden dev build of the PR head (ef7aed88dea) and of its base on main (b8a0554), with stand-in Pi and Grok agents on a fresh profile. Steps, for each agent: default X, open chat A and send (A starts on X); change the default to Y, open chat B and send (B starts on Y); switch back to chat A; open new chat C and capture its first frame before sending anything. Pi: X = GPT-6.1 Sol, Y = Claude Sonnet 4. Grok: X = Grok Build, Y = Grok 4.7.

Pi, before (main): chat C opens on GPT-6.1 Sol, the old default, although chat B had started on Claude Sonnet 4.
main-pi-chat-c-first-frame.png

Pi, after (this PR): chat C opens on Claude Sonnet 4, the newest default.
pr-pi-chat-c-first-frame.png

Grok, before (main): chat C opens on Grok Build, the old default.
main-grok-chat-c-first-frame.png

Grok, after (this PR): chat C opens on Grok 4.7, the newest default.
pr-grok-chat-c-first-frame.png

Pi, this PR: once one Pi chat has started, the next new Pi chat names that default (GPT-6.1 Sol) on its first frame, before its own agent starts.
pr-pi-next-chat-first-frame.png

Testing

  • I manually tested these changes locally
  • Automated tests added/updated, or explained why not below

Run by file name on macOS, on the Node runtime where the repo requires it:

  • Report once, Pi (src/main/pi/rpc-configured-default.test.ts): chat A started on GPT-6 reads its options again after chat B taught Claude Sonnet 4, and the next new chat still names Sonnet. Also:
    • a chat that restored only a thinking level teaches the model but not the thinking level;
    • a new session standing in for a conversation whose file was gone teaches;
    • the host saves the start listing first, then each read, as it does in the app.
  • Report once, Grok (acp-structured-host-configured-default.test.ts, real Grok probe, adapter and host): chat A runs 4.6; a newer chat teaches 4.7; A reads its options again; the next new chat names 4.7. Save a new chat's first message during create #26332's "created with its first message" case now also checks that the default is learned before any options read.
  • acp-structured-host-live-listing.test.ts expects the start's save plus the read's, with no default on the read.
  • Report once, Claude (claude-structured-configured-default.test.ts, real Claude adapter over a scripted CLI): chat A teaches Sonnet; a newer chat teaches Opus; A reads its options again, and its listing carries no default, so Opus stands. The other Claude configured-default tests now learn from the start's report. structured-agent-session-provider-started.test.ts (real Claude adapter and host) checks started saves the listing without a default, the settings readback saves it with one, and a later read saves none, including for Save a new chat's first message during create #26332's chat created with its first message. claude-structured-effort-default-at-rest.test.ts learns from the start's report.
  • Effort menu (agent-model-catalog-entry.test.ts, new): a chat's menu wins over a newer background menu that lacks the learned effort; an older background menu never wins over a chat's newer one.
  • Pi config (agent-project-model-override.test.ts):
    • packages counts;
    • an extensions folder that can't be read counts;
    • an empty or BOM-only settings file and a dotfile-only extensions folder don't count.

Fail without the change (checked by putting the old code back and running the tests):

  • Options reads re-reporting the start's default: the "later reads teach nothing" cases fail for Pi, Grok and Claude. So do the live-listing case and Save a new chat's first message during create #26332's Claude "created with its first message" case.
  • The host ignoring the readback's listing: the Claude provider-started cases ("saves the account listing the child read at startup", "created with its first message") fail.
  • The host's start hand-off removed: the Grok "names 4.6" case and the "created with its first message" case fail.
  • The previous effort merge: the "older background menu never wins" case fails.
  • The readback checked as it arrived (not behind the attach): "saves the configured default when the settings readback beats the attach" fails (the attach is slowed so the readback lands first).
  • The start hand-off inside the acquire's cleanup scope: "starts the chat even when saving its start listing throws" fails.
  • The previous Pi config check: the empty/dotfile case and the unreadable-folder case fail.

Also passing: every ACP and Pi test file, the host attach, acquisition, provider-started and create tests (including #26332's), the catalog tests, the first-frame tests, the Claude and Codex configured-default tests, the renderer learned-default test and the SQLite runtime boundary test (291 files, 2942 tests, including every Claude test file except the two real-CLI ones).

  • Real Claude CLI (required by AGENTS.md for Claude structured-session changes), run once on 98d85423eee: claude-structured-real-cli.test.ts and claude-structured-real-cli-fold.test.ts, 13 of 13 pass. Since then the Claude files changed only by renaming a parameter (a boolean became { withConfiguredDefault }), with no behavior change, so it wasn't run again.

Checks:

  • pnpm tc:node, pnpm tc:web and oxlint on changed files pass.
  • orca-ci-checks: 22 of 23 pass. The one failure is audit:code-quality:native, the existing import-cycle warnings in mobile/src/transport and mobile/src/terminal/document; no file of this PR is involved.

CI: see the status below; cross-version wire compatibility is red on main itself. It fails 7 tests in qoder-history-search-downgrade and session-search-agent-set-downgrade, because an old build's session-search agent list lacks kiro. Run 37988257228 (brennanb2025/native-chat-agent-checks-use-registry, based on main 2cb2bef67e3, 4 files changed, none touching search) fails the same 7. The aggregate verify that requires that job fails with it. Since 98d85423eee, package (Linux) fails while pulling the node:22-bookworm-slim image from Docker Hub (429 Too Many Requests, then on ef7aed88dea a 504 Gateway Timeout from Docker's auth server). It passed on 2427a7c5bd7, and package (windows) passes.

Not verified: the running app, real agent CLIs, SSH/WSL hosts beyond the shared code paths (the save and the workspace check run on the execution host), and the phone.

AI Disclosure

Review

Agent skill upstream boundary

  • Not applicable, or this change copies or mechanically translates no upstream skill-installer source, tests, fixtures, registry entries, path tables, comments, or documentation.

Notes

Ensure no issues in: Security, Cross-platoform support (Linux, Windows, Mac), Remote SSH, Mobile, general backwards compatibility, performance

  • Remote/SSH: the start report is saved by the execution host's own catalog, as before. No wire change.
  • Mixed versions: clients read the same catalog answer; an older host keeps its old timing.
  • Windows: the unreadable-folder test is skipped there, since Windows has no permission bits to make a folder unreadable.

Checklist

  • This PR is small and focused
  • I explained what changed and why (ELI5, the user-facing before/after, the mechanism, and why over the alternatives)
  • Before/after screenshots or videos attached for UI changes, or N/A with reason
  • Self-reviewed for correctness, security, and performance
  • Cross-platform, SSH/remote, and path/shortcut impact considered (or N/A)
  • pnpm lint, pnpm typecheck, pnpm test, and pnpm build pass (or CI will cover; local preferred)

…tart

Every options read re-reported a no-pick chat's configured default: ACP
re-derived it from the session's current state, and Pi re-sent its start
snapshot. A chat left open kept writing its start model back over a newer
default another chat had learned since (for example after the user changed the
agent's own default), so new chats named the old model and then jumped.

A ready child's acquisition now carries its start listing, with the default its
start resolved after restoring the chat's saved picks, and the host saves it
once when the owner is proven (a starting child already hands it on its
`started` event). Options reads carry the model list only. Grok, OpenCode, OMP
and Pi share this path and the one no-pick rule.
The configured-effort fallback took the older listing's menu whenever it had
the effort, so an older background listing could bring back an effort the
agent's own newer menu no longer offers. It now falls back only to the chat's
own listing.
…s folder picks nothing

Pi reads an empty settings file as no settings, and a .pi/extensions folder
holding only .DS_Store or .gitkeep loads no extension, so neither keeps the
"Model" placeholder any more. The BOM strip is written as an escape. Tests
cover the packages key and an extensions folder that can't be read.
… start's settings readback

Claude's configured default rode on every options read, so an open Claude chat
with nothing picked kept writing back the model its settings applied when it
started, over a newer default another chat had learned since. Its default is
known only once the settings readback after `started` lands, so that readback's
`options-reported` event now carries the start listing with the resolved
default, and the host saves it once for the child that reported it. Options
reads carry the model list only, as for every other agent.
…h, and never fails the start

Claude's settings readback, its one report of what the config resolved, was
checked against the host's child index as it arrived. A readback that beat a
slow attach found no child yet and was dropped for good. It now waits in the
session's serialized step behind that attach, as `started` does.

Handing a ready child's start listing to the catalog ran inside the acquire's
cleanup scope, so a throw there would have torn down a proven child. It is
bookkeeping: a failure is logged and the start proceeds.
…ption; Pi's start listing is read-only

No behavior change: claudeCatalogListing takes { withConfiguredDefault } instead
of a trailing boolean, and PiRpcSession exposes its start listing through a
getter over a private field.
@brennanb2025

Copy link
Copy Markdown
Contributor Author

Review summary

Review: one full review round, with no P0/P1 findings. The findings were fixed in c7f080f and ef7aed8:

  • Claude's learned default could be lost when its settings readback arrived before the chat was registered. It is now saved after registration. A test fails without the fix.
  • A failure to save the learned default could stop a chat that had started fine. It is now logged and the chat starts. Covered by a test.
  • PR body: Codex has no learned-default path (intended). Claude shares the timing, not the shared rule.
  • Checked: OpenCode 2.0.14 / 1.18.31, OMP 18.4 and Grok send their model list as part of starting, so a list sent later can't be missed.

Visual proof (in the body): live before/after on Windows with stand-in Pi and Grok agents. Each screenshot was checked against the stand-in logs. On main, after switching back to an older chat, the next new chat shows the old default. With this PR it shows the newest one. The proof also shows that a new Pi chat's first frame names its learned default; #26821 merged without that proof. Real agent homes were hashed before and after, and were unchanged.

Found during QA, not caused by this PR: a new Grok/Pi/Codex chat's effort picker shows a blank "Effort" until the agent starts. Main has the same gap: when no agent is running yet, the chat's options answer drops the default effort for every agent except Claude. A separate follow-up PR fixes it.

CI: cross-version wire compatibility fails on main's own "kiro" search tests; branches that don't touch search show the same 7 failures. verify only reports that result. package (linux) failed pulling from Docker Hub (504) and was rerun. Everything else passes. The PR's files, merged with current main, pass lint, including file length.

Not verified: real agent CLIs this round (the real Claude CLI test passed 13/13 on 98d8542), SSH/WSL hosts, and mobile.

@brennanb2025
brennanb2025 marked this pull request as ready for review October 9, 2026 23:47
@brennanb2025
brennanb2025 merged commit 7450f40 into main Oct 9, 2026
60 of 65 checks passed
@brennanb2025
brennanb2025 deleted the brennanb2025/learned-default-once branch October 9, 2026 23:57
brennanb2025 added a commit that referenced this pull request Oct 10, 2026
… start

#26982 reported a no-pick chat's configured default once, from a ready child's
acquisition. With every acquire now returning at spawn, the start listing rides
on `started` instead: ACP's after its option restore, Pi's from the model list
its start reads. The host saves it inside the `started` step, and a failure to
save it is logged, never the start's. Claude keeps its default on the settings
readback, saved behind the attach. The acquisition hook and its field are gone.

Also moves the start-listing saves and the Stop note's turn scope into their own
modules to keep the merged files under the line limit.
brennanb2025 added a commit that referenced this pull request Oct 15, 2026
#26982)

* fix(native-chat): a chat says what its config resolved once, at its start

Every options read re-reported a no-pick chat's configured default: ACP
re-derived it from the session's current state, and Pi re-sent its start
snapshot. A chat left open kept writing its start model back over a newer
default another chat had learned since (for example after the user changed the
agent's own default), so new chats named the old model and then jumped.

A ready child's acquisition now carries its start listing, with the default its
start resolved after restoring the chat's saved picks, and the host saves it
once when the owner is proven (a starting child already hands it on its
`started` event). Options reads carry the model list only. Grok, OpenCode, OMP
and Pi share this path and the one no-pick rule.

* fix(native-chat): only a chat's own effort menu outranks a newer one

The configured-effort fallback took the older listing's menu whenever it had
the effort, so an older background listing could bring back an effort the
agent's own newer menu no longer offers. It now falls back only to the chat's
own listing.

* fix(native-chat): an empty Pi settings file or dotfile-only extensions folder picks nothing

Pi reads an empty settings file as no settings, and a .pi/extensions folder
holding only .DS_Store or .gitkeep loads no extension, so neither keeps the
"Model" placeholder any more. The BOM strip is written as an escape. Tests
cover the packages key and an extensions folder that can't be read.

* fix(native-chat): Claude says what its config resolved once, with its start's settings readback

Claude's configured default rode on every options read, so an open Claude chat
with nothing picked kept writing back the model its settings applied when it
started, over a newer default another chat had learned since. Its default is
known only once the settings readback after `started` lands, so that readback's
`options-reported` event now carries the start listing with the resolved
default, and the host saves it once for the child that reported it. Options
reads carry the model list only, as for every other agent.

* fix(native-chat): a start's learned default is saved behind the attach, and never fails the start

Claude's settings readback, its one report of what the config resolved, was
checked against the host's child index as it arrived. A readback that beat a
slow attach found no child yet and was dropped for good. It now waits in the
session's serialized step behind that attach, as `started` does.

Handing a ready child's start listing to the catalog ran inside the acquire's
cleanup scope, so a throw there would have torn down a proven child. It is
bookkeeping: a failure is logged and the start proceeds.

* refactor(native-chat): name the Claude listing's configured-default option; Pi's start listing is read-only

No behavior change: claudeCatalogListing takes { withConfiguredDefault } instead
of a trailing boolean, and PiRpcSession exposes its start listing through a
getter over a private field.

(cherry picked from commit 7450f40)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant