Skip to content

feat(providers,schema,engine,workbench): retire the default model — user-selected model sets from provider model lists - #426

Open
PeronGH wants to merge 17 commits into
masterfrom
zichen/code-574
Open

feat(providers,schema,engine,workbench): retire the default model — user-selected model sets from provider model lists#426
PeronGH wants to merge 17 commits into
masterfrom
zichen/code-574

Conversation

@PeronGH

@PeronGH PeronGH commented Aug 6, 2026

Copy link
Copy Markdown
Member

Closes CODE-574.

Model selection was a free-text field plus a fixed per-agent list that ignored which account was bound. This replaces both with a set of model ids picked per account: fetch what the service serves, tick what you want, and the composer offers exactly that. Ids can also be typed by hand, which is how endpoints that serve no list work.

Not a bug fix — nothing was broken. It removes the guessing.

Breaking

WIRE_PROTOCOL_VERSION and MIN_COMPATIBLE_WIRE_VERSION both move to 74: Account.model is removed, ProviderConfig.defaultModel renamed to model, and StartOptions.model loses its null tier. Rebuild and restart the daemon and all clients together — a peer below the floor has its frames refused and dies in the handshake timeout.

loadConfig migrates on read (Account.modelmodels: [{id}], defaultModelmodel). Without it zod strips the unknown keys and existing users lose their configured model.

Commits

commit scope
3a068640 providers — per-service model-list URL
2d5dc3a1 schema + engine — Account.models, ProviderConfig.model, probe reshape, wire 74, migration
0bae7397 agent-adapter — opencode bare-id qualification
f7435f85 workbench + i18n — multi-select, Refresh, freeform add
c430eba8 workbench + ui — picker source, send gate, AGENT_DEFAULT_MODELS removal

Non-obvious bits

The list URL is hardcoded per service rather than derived from the resolved variant, because derivation is wrong wherever variants sit on different paths — DeepSeek's anthropic variant would give /anthropic/v1/models, Vercel's bare-origin one a root /models. All six URLs checked against vendor docs; both Cloudflare services carry none, since /compat has no models route.

In the account's model set, present-but-empty and absent differ. [] means an account is bound with nothing picked, so sending is blocked to match the daemon's refusal. Absent means no account is bound, where the agent resolves its own model and is not blocked — blocking there would break opencode/pi on their own auth.

Ids only, no metadata. Both provider-routed agents accept a bare id and fill the rest themselves, and for pi declaring a model it already knows is harmful: applyModelsJson replaces on id match, so redeclaring deepseek-v4-pro overwrites its real 1M context window with pi's 128k default. Checked against opencode's config schema (all Model fields optional, v1 and v2) and pi's modelFromJson.

Fetch sources are injected rather than called in the forms, which are presentation and sit outside the data-plane provider tree. Catalog services probe with the unsaved secret, saved accounts probe by id so the stored secret stays daemon-side, and subscriptions read codex's start catalog or — for claude-code, which has no enumeration API — the curated table.

Selection lives in the edit form rather than the account detail pane as CODE-574 task 5 described, since EditAccountForm already routes non-OAuth accounts through CustomAccountForm.

Checks

pnpm check:ci exits 0. pnpm test: 2742 passing. Two failures are outside this change's module graph — the known packages/host/assets registry loopback test, and packages/host/engine run-command's timeout assertion, which passes in isolation and whose imports (node:child_process, effect, foxts/noop, ../observability) this branch never touches.

Not driven in the real app. Needs a live DeepSeek key: add the account, Refresh, multi-select, confirm the composer offers that set for every bound agent, confirm send is blocked with none picked, start a session on a picked model, and confirm an upgraded config keeps its previous model.

Follow-ups

  • Our cloudflare-gateway entry points at /compat/chat/completions, which Cloudflare has deprecated in favour of api.cloudflare.com/client/v4/accounts/{ACCOUNT_ID}/ai/v1/chat/completions — a different URL shape, so it needs its own issue.
  • Anthropic's /v1/models returns per-model effort capabilities and display_name in a response we already parse; useful for the effort picker.

PeronGH added 5 commits August 6, 2026 17:20
Each endpoint service names the URL that lists the ids it serves, spelled out
rather than derived from a variant's baseUrl and protocol: DeepSeek's
`/anthropic` variant would derive `/anthropic/v1/models` and Vercel's
bare-origin one a root `/models`, and neither route exists. Both Cloudflare
entries serve no list at all, so they stay absent and those accounts remain
freeform-only.
…el source

An account now carries the models the user selected (`Account.models`) instead
of one free-text default, and the pick itself lives per agent as
`ProviderConfig.model`. Nothing falls back to the agent's own choice any more,
so a bound agent with no pick refuses to start rather than running on a model
the user never chose; an agent with no account bound keeps resolving its own.

`config.probe-models` now names a service and lets the daemon resolve the list
URL from the catalog, so a saved account is probed by id and its stored secret
never travels back out to the client.

Both wire versions move: removing `Account.model`, renaming `defaultModel`, and
dropping the `null` tier from `StartOptions.model` are breaking. `loadConfig`
carries both old fields over on read, since zod would otherwise strip them and
silently lose every existing user's configured model.

The model inputs are gone from the account forms; the multi-select that replaces
them lands with the picker work.
…known provider

A picked model id comes from the service's own model list and carries no
provider, while opencode routes only by `providerID/modelID`. `resolveModelRef`
qualifies it with `config.knownProvider`; with neither half available the ref is
still refused, since a stored unroutable id would report a successful switch
while every prompt silently omitted the field.

Both reflection paths compare resolved refs and emit the id the user picked
rather than opencode's prefixed readback, so the client's selected set still
matches what the session reports.
… list

Every account form now carries a model set instead of a free-text default: fetch
the ids the service serves, tick the ones to keep, and add any missing id by
hand. Endpoints that serve no list — the Cloudflare gateways, custom accounts —
are freeform only, which is the same control with the fetch button absent.

Sources differ by account and are injected rather than read in the form, since
the forms are presentation and only the settings page sits inside the data-plane
provider tree: a catalog service is probed with the unsaved secret, a saved
account by id, and a subscription reads codex's start catalog or, for
claude-code, the curated table it has no enumeration API to replace.

A picked id the list stops returning is kept and stays ticked. It is either a
hand-typed entry or one the vendor retired, and dropping it would change the
account's model set behind the user's back on the next fetch.
… to send without one

The composer and the new-session surface now read the set picked on the agent's
bound account, which outranks both the adapter-advertised catalog and the curated
table: a claude-code account pointing at DeepSeek stops offering Anthropic ids it
cannot reach.

Present-but-empty and absent mean different things in that set, and the send gate
turns on the difference. An account bound with nothing picked blocks sending,
matching the daemon's own refusal instead of discovering it a round trip later.
An agent with no account bound is absent, still resolves its own model, and is
not blocked.

AGENT_DEFAULT_MODELS is gone: guessing a provider's model is exactly what the
picked set replaces, and an unresolved model now blocks the send rather than
silently starting on a vendor default. Rebinding an agent drops a pick the new
account does not list, since keeping it would run the next session on a model
that account never offered.
Copilot AI lite review requested due to automatic review settings August 6, 2026 12:22
@linear-code

linear-code Bot commented Aug 6, 2026

Copy link
Copy Markdown

CODE-574

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

The reshaped config.probe-models frame lets a caller pair any saved account's secret with any service's model-list URL. A crafted frame sends a stored Anthropic key to openrouter.ai. Details inline on request-handler.ts.

The direction here is right, and the schema work is careful — ProviderConfig.model's "Not a fallback default: unset means no session can start" is exactly the doc comment that makes the new contract legible, and AGENT_DEFAULT_MODELS is removed cleanly (I grepped: zero stragglers). The MIN_COMPATIBLE_WIRE_VERSION 68→74 bump is the correct call for a field rename plus a removed null tier, and the PR body already says partial upgrades are off the table.

Two anchored defects below, plus one scope question that has no line to point at.

The orphan-model drop exists in only one place, and it's client-side

withBinding (packages/client/workbench/src/settings/providers/view.ts) now takes accounts and drops providers[kind].model when the newly bound account doesn't offer it. Good — but that's the rebind path only.

Nothing re-runs it when the account's models set is edited. handleUpdate in providers-settings.tsx calls saveAccounts and never touches providers, so a user who unchecks the very model an agent is configured to run on leaves providers[kind].model pointing at an id the account no longer offers. On the engine side, applyProviderDefaults (packages/host/engine/src/agent/provider-config.ts:137) does next.model = config.model verbatim — the account's models set is used only to build the credential/endpoint bundle, never to validate the model string. The new accountBound && model === undefined guard in start-options-resolver.ts doesn't fire either, because the model is present, just wrong.

Net effect: the user gets a provider-level failure mid-start ("model X is not available…", or a 400 from the vendor) instead of the clean, local "no model selected" refusal this PR is built to produce. Since the engine is where the invariant is now enforced, that's probably where membership belongs too — the client-side drop is a nicety, not the guarantee. Worth deciding deliberately rather than leaving the two halves out of step.

Smaller notes

  • packages/host/agent-adapter/src/__tests__/pi-model.test.ts:177 still asserts start({ model: null }) preserves "an explicit null model reset for the Pi provider default". That tier is no longer expressible on the wire now that AgentStartInput.model dropped .nullable(), so the test is pinning adapter behavior no client can reach. Not wrong, just worth knowing it's decorative now.
  • The six vendor model-list URLs in catalog.ts are hardcoded and I could not verify them from here (no network). Worth one manual pass, particularly deepseek's https://api.deepseek.com/models and the ?limit=1000 on anthropic-api.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using Claude Opus𝕏

Comment on lines +216 to +228
private probeSecret(
credential: Extract<AgentRequest, { kind: 'config.probe-models' }>['credential'],
): AccountSecret {
if (credential.type === 'inline') return credential.secret;
const account = this.providers
.getAccounts()
.find((candidate) => candidate.id === credential.accountId);
if (!account) throw new Error('Account not found');
if (account.credential.type === 'oauth') {
throw new Error('A subscription login holds no secret to read the model list with');
}
return account.credential;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The destination URL and the credential come from two independent client-controlled fields, and nothing checks they agree.

At line 154 the URL is derived purely from payload.service:

const source = modelListSource(payload.service);
...
const models = await this.probeModels(source, this.probeSecret(payload.credential));

and probeSecret below resolves the secret purely from credential.accountId. So a config.probe-models frame carrying service: 'openrouter' plus credential: {type:'account', accountId: '<the-anthropic-account>'} makes the daemon send the stored Anthropic key to https://openrouter.ai/api/v1/models.

That directly contradicts this method's own docstring. "A saved account is named by id rather than shipping its secret back out to the client" keeps the secret away from the client, but the caller still gets to choose which third party receives it — which is the part that actually matters. The blast radius is bounded (six catalog URLs, not arbitrary-URL SSRF, and the client is loopback-local), but "only a local process can exfiltrate your API keys to a competitor" is still a weaker guarantee than the one written here.

The information needed for the check already exists: Account.service (packages/foundation/schema/src/model/account.ts:58). Thread the service id in and compare. Note the undefined case — custom and pre-catalog accounts have no service, and those should be refused rather than allowed through, since modelListSource only ever resolves a catalog id anyway.

Suggested change
private probeSecret(
credential: Extract<AgentRequest, { kind: 'config.probe-models' }>['credential'],
): AccountSecret {
if (credential.type === 'inline') return credential.secret;
const account = this.providers
.getAccounts()
.find((candidate) => candidate.id === credential.accountId);
if (!account) throw new Error('Account not found');
if (account.credential.type === 'oauth') {
throw new Error('A subscription login holds no secret to read the model list with');
}
return account.credential;
}
/** The secret to probe with. A saved account is named by id rather than shipping its secret back
* out to the client and in again; an oauth login holds none, so it cannot be probed. The account
* must belong to the service being probed, or the caller could aim one vendor's key at another. */
private probeSecret(
service: string,
credential: Extract<AgentRequest, { kind: 'config.probe-models' }>['credential'],
): AccountSecret {
if (credential.type === 'inline') return credential.secret;
const account = this.providers
.getAccounts()
.find((candidate) => candidate.id === credential.accountId);
if (!account) throw new Error('Account not found');
if (account.service !== service) {
throw new Error('Account does not belong to the service being probed');
}
if (account.credential.type === 'oauth') {
throw new Error('A subscription login holds no secret to read the model list with');
}
return account.credential;
}

Comment on lines +190 to +195
// The catalog default is what the agent's own config would start on, so it yields to anything the
// user expressed through LinkCode. Nothing guesses past it: an unresolved model blocks the send
// rather than starting a session on a model nobody chose.
const displayedModel =
selectedModel ??
(defaultModels === null
? null
: (defaultModels?.[provider] ??
catalog?.defaultModel ??
AGENT_DEFAULT_MODELS[provider] ??
null));
(defaultModels === null ? null : (defaultModels?.[provider] ?? catalog?.defaultModel ?? null));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new comment says "an unresolved model blocks the send rather than starting a session on a model nobody chose" — but the catalog?.defaultModel arm on line 195 means it doesn't.

catalog.defaultModel is still populated by the adapters (native/claude-code.ts:623, native/pi/adapter.ts:223, native/codex/adapter.ts:418-426). Take an agent with an account bound and a non-empty picked set, where the user has never chosen a model — so selectedModels[provider] is undefined, preferredModels?.[provider] is absent, and configuredDefaultModels has no entry because providers[kind].model is unset. Then:

  • selectedModel is null, so the ?? on line 194 does not short-circuit;
  • displayedModel resolves to catalog.defaultModel, a non-null string;
  • boundSet is defined, so sendBlocked = (boundSet !== undefined && displayedModel === null) is false;
  • resolveModel(pickable, displayedModel) finds nothing, so the composer displays a model the bound account may not even offer;
  • submit (line 240) evaluates localModel === null ? undefined : (selectedModel ?? undefined)undefined;
  • the daemon rejects with No model selected for <kind>.

That's the exact round trip this PR set out to eliminate, and it shows up in the default state for any freshly bound account. Note the fix must keep the defaultModels?.[provider] arm — a configured model legitimately submits as undefined and gets refilled by applyProviderDefaults — and drop only the catalog fallback:

Suggested change
// The catalog default is what the agent's own config would start on, so it yields to anything the
// user expressed through LinkCode. Nothing guesses past it: an unresolved model blocks the send
// rather than starting a session on a model nobody chose.
const displayedModel =
selectedModel ??
(defaultModels === null
? null
: (defaultModels?.[provider] ??
catalog?.defaultModel ??
AGENT_DEFAULT_MODELS[provider] ??
null));
(defaultModels === null ? null : (defaultModels?.[provider] ?? catalog?.defaultModel ?? null));
// The catalog default is what the agent's own config would start on, so it yields to anything the
// user expressed through LinkCode. Nothing guesses past it: an unresolved model blocks the send
// rather than starting a session on a model nobody chose. A bound account narrows that further —
// its picked set is the only source, so the adapter's own default is not a candidate at all.
const displayedModel =
selectedModel ??
(defaultModels === null
? null
: (defaultModels?.[provider] ??
(accountModels?.[provider] === undefined ? catalog?.defaultModel : undefined) ??
null));

PeronGH added 3 commits August 6, 2026 20:52
A live session's account is fixed at spawn — credentials and base URL are
injected once — so the client needs to know it to scope that session's model
menu. Nothing recorded it: the resolved account existed only inside
`applyProviderDefaults`.

`accountConfigBundle` now echoes `accountId` into the resolved config, which
`resolveAccount` already reads on the way in, so the same key serves both
directions and a client can pin a session to one account. Each run lifts just
that id into `SessionRun`, and `SessionInfo` reports the latest run's, mirroring
how `historyId` already works — a rebind between runs is legitimate, so only the
newest describes what a session is actually talking to.

Only the id is persisted; the rest of `config` carries secrets.
The new-session menu now spans every account `resolveBinding` accepts for the
agent, grouped per account, so choosing a model also chooses which account
serves it. One agent reaches several providers without a trip through Settings —
previously the menu showed only the single bound account's set, which made it
offer less than the curated table it replaced.

A live session's menu stays scoped to its own account: credentials and base URL
are injected at spawn, so offering another account's models would advertise a
switch the adapter cannot make.

Model identity becomes (account, model). Two accounts legitimately serve the
same id — a direct DeepSeek key and an OpenRouter one both list
`deepseek-v4-pro` — and the menu previously used the bare id as both its React
key and its radio value, which would collapse the two into one unselectable row.
`modelChoiceKey` keys them apart, the pick hands back the whole entry rather than
a string to re-parse, and `resolveModel` takes an account tiebreak so the trigger
label names the right one.

The chosen account rides `config.accountId`, which `resolveAccount` already
honours ahead of the bound one.
The model an agent runs on was remembered twice: `providers[kind].model` on the
daemon and `modelsByProvider` in renderer localStorage. Two owners meant Settings
and the composer could disagree, and a scheduled or script session ignored
whatever the composer last used.

The accepted pick now writes daemon config, carrying the account it came from —
choosing a model is also choosing who serves it, so leaving the old binding would
run the next session on an account that never listed that model. The client copy
is gone and the persisted store moves to v6 so a stale blob cannot resurrect a
memory with no owner.

Written once a selection is known to have been accepted rather than on the menu
click, keeping the existing confirm-then-remember discipline: an abandoned draft
never rewrites config, and a provider that rejects a model leaves the previous one
standing. The pick still takes effect on the session immediately — it rides the
start options either way.

Nothing re-sends a configured model at session start now; the daemon resolves it,
so the client specifying it again could only let the two disagree.
Copilot AI review requested due to automatic review settings August 6, 2026 13:34

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@PeronGH

PeronGH commented Aug 6, 2026

Copy link
Copy Markdown
Member Author

Three commits pushed that close a gap the first five left open. The Provider → Harness rename follows separately, so the textual sweep does not hide logic.

What was missing

The original five made the picker read the one account bound to an agent, which narrowed it — claude-code offered whatever was ticked on one account instead of the whole curated table it replaced. ProviderConfig.activeAccountId is a single string, so reaching another provider's model still meant Settings → rebind → return → re-pick, and withBinding dropped the pick on rebind. "Pick from your models" was capped at one provider per agent for no structural reason.

commit scope
c7f4e647 each session run records the account it resolved to; SessionInfo reports the latest
1608dc43 the new-session menu spans every bindable account; model identity becomes (account, model)
8634d2c8 the pick gets one owner in daemon config, rebinding the agent to the model's account

How it works now

The new-session menu aggregates every account resolveBinding(account, kind) accepts, grouped per account, so choosing a model also chooses who serves it. The chosen account rides config.accountId — a seam resolveAccount already honoured ahead of the bound account, and which nothing in the client had ever set.

A live session's menu stays scoped to its own account. Credentials and base URL are injected at spawn, so offering another account's models would advertise a switch the adapter cannot make — opencode rejects cross-provider outright, and claude-code would send the new id to the old endpoint. That required the session to start recording its account, which nothing did.

The accepted pick writes providers[kind].model and activeAccountId together, and the renderer-localStorage copy (modelsByProvider) is gone. Two owners previously meant Settings and the composer could disagree, and a scheduled session ignored whatever the composer last used.

Three findings worth attention in review

The menu-keying bug was real, not hypothetical. composer-controls.tsx used the bare model id as both the React key and the radio value. Two accounts serving deepseek-v4-pro — a direct key and an OpenRouter one, or a work and personal key — would collapse into one unselectable row, so cross-account picking could not have worked without modelChoiceKey. Covered by a test asserting two distinct entries survive.

The send gate from c430eba8 is corrected here. It was keyed on "an account is bound"; it is now "any account can back this agent", which is what the aggregation actually knows. Absent still means unbound-and-not-blocked, so opencode/pi running on their own auth are unaffected — there is a test for both halves.

Configured models are no longer re-sent at session start. Three tests asserted the remembered pick travels in the submission; that only held while the memory was client-side and invisible to the daemon. The daemon now resolves providers[kind].model itself, so the client restating it could only let the two disagree. Those tests were rewritten to assert the display plus the absence of a redundant override, not deleted.

Deliberate deviation

The pick takes effect on the session immediately, but the persisted default is written only once the selection is confirmed — keeping the existing newlyConfirmedStartupSelection discipline, which exists because a provider can reject a model. So an abandoned draft never rewrites config, and Settings reflects a rebind after the first successful turn rather than on the menu click.

Checks

pnpm check:ci exits 0. pnpm test: 2749 passing, with the known packages/host/assets registry loopback failure — outside this change's module graph.

Still not driven in the real app, which remains the gap for this PR overall. The manual pass now needs two accounts bindable to one agent: confirm the new-session menu groups both, that picking a DeepSeek model starts a session actually running on DeepSeek, that Settings shows the rebind after the first turn, that the live thread's menu shows only that account's models, and that two accounts sharing a model id give two separately selectable entries.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

The new commits widen the send-gate from bound to bindable. One unrelated API-key account in the pool is now enough to replace opencode's own model menu with that account's models and block the composer — while the daemon, which gates on activeAccountId, would have started the session fine. Details inline on default-models.ts.

The account-pinned model pick is a good design, and the pieces that make it work are careful: modelChoiceKey identifies an entry by (accountId, id) rather than id alone, resolveModel's account scoping falls back instead of returning nothing, and handleModelChange writing model and account together is the right shape. Account.models' doc comment states the new contract plainly.

Three new defects below, all in the two newest commits. Independent of these, both threads from the previous review are still open at this HEAD — probeSecret still takes no service argument, and the catalog?.defaultModel arm in displayedModel is unchanged. Not re-raised here.

Two comments in this PR contradict each other, and the tests encode the wrong one

start-options-resolver.ts:52-54, on the daemon's new refusal:

With an account bound, its selected set is the only model source and nothing falls back to the agent's own choice. Unbound agents keep running on whatever they resolve themselves.

accountModelOptions' doc, on the client's:

[] says "bindable, nothing picked yet" and blocks sends the way the daemon does.

Those describe different gates. The daemon keys on providers[kind].activeAccountId !== undefined; the client keys on whether any account could theoretically back the agent — which for opencode and pi is nearly every API-key account in the pool (resolve.ts:149-151 returns native for every protocol, and resolve.ts:66-69 keeps a service-less bare key bindable everywhere). opencode and pi are exactly the agents the PR body says must keep running on their own CLI login.

The new tests preserve the conflation rather than catching it. new-session-surface.test.tsx:898 is titled "refuses to send when an account is bound but no model is picked" and comments "Bound with an empty set: the daemon would refuse this start, so the composer does too" — but the prop it passes, accountModels={{ 'claude-code': [] }}, is what accountModelOptions produces for a bindable account, per default-models.test.ts:104-111 ("keeps a bindable-but-unpicked one empty"). The test passes while asserting a parity the code does not have, so the gap is invisible from the suite.

Whichever way you settle it, the two gates should read the same field — otherwise the composer and the daemon will keep disagreeing about which agents are startable.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using Claude Opus𝕏

Comment on lines +45 to +55
export function accountModelOptions(
accounts: Accounts | undefined,
): Partial<Record<AgentKind, ModelOption[]>> {
const options: Partial<Record<AgentKind, ModelOption[]>> = {};
for (const kind of AgentKindSchema.options) {
const bindable = (accounts ?? []).filter(
(account) => resolveBinding(account, kind).tier !== 'unavailable',
);
if (bindable.length === 0) continue;
options[kind] = bindable.flatMap((account) => modelOptionsOf(account));
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bindable filters on resolveBinding(...).tier !== 'unavailable'could this account back the agent, not is it bound. For opencode and pi, bind() returns native for every protocol (resolve.ts:149-151), and an account with no catalog service stays bindable everywhere (resolve.ts:66-69). So one API-key account added for codex makes options.opencode present for a user whose opencode authenticates through its own opencode auth login.

Presence is load-bearing twice over in new-session-surface.tsx:

  • pickable = bindableSet ?? dynamicModels ?? AGENT_MODEL_OPTIONS[provider] (line 206) — a present entry discards opencode's own live catalog, so the menu shows an unrelated vendor's models.
  • sendBlocked = bindableSet !== undefined && displayedModel === null (line 383).

opencode is the one adapter that deliberately publishes no defaultModel (native/opencode/adapter.ts:751), and providers.opencode.model is unset because the account was never bound — so displayedModel is null and the send is blocked. The user's only way forward is to pick a model belonging to a vendor account they never meant to use for opencode, which then pins accountId to it and rebinds the agent.

It gets worse when that account has no picked models. models is optional and modelOptionsOf does account.models ?? [], so options[kind] is [] — present, hence blocking, but with nothing in the menu. default-models.test.ts:104-111 pins exactly this ("keeps a bindable-but-unpicked one empty"). The agent is then unstartable from the composer and no interaction there can recover it.

Meanwhile start-options-resolver.ts:52-54 says the opposite for the same configuration: "Unbound agents keep running on whatever they resolve themselves." It gates on activeAccountId, so it would have started this session.

The PR's intent — one agent drawing models from several accounts — argues against narrowing this function to the bound account, since the wider menu is the feature. The narrower fix is downstream: gate the send on the agent actually being bound (the field the daemon uses), and have pickable union the account sets with the adapter catalog instead of replacing it, so an unbound agent keeps its own models on offer.

Comment on lines +415 to +427
if (newlyConfirmed.model === undefined && newlyConfirmed.effort === undefined) return;
rememberSelection(submission.kind, newlyConfirmed);
if (newlyConfirmed.effort !== undefined) {
rememberSelection(submission.kind, { effort: newlyConfirmed.effort });
}
// The model lands in daemon config rather than a client store, together with the account it
// came from, so Settings shows the rebind and non-composer sessions inherit the pick.
if (newlyConfirmed.model) {
void persistPickedModel(
submission.kind,
newlyConfirmed.model,
submission.accountId,
).catch(noop);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

persistPickedModel runs only on the late-confirmation path, so the ordinary case never persists.

newlyConfirmedStartupSelection requires initial.model === null (startup-selection.ts:59) — that is, it fires only when the adapter failed to reflect the pick at session start. When the adapter reflects immediately, reflectedStartupSelection at line 392 already set startupSelection.model = requested.model, so the precondition is false, newlyConfirmed.model is undefined, and the guard on line 415 returns before line 421 is reached.

Nothing else records it now: selectionPatch returns only { effortsByProvider }, modelsByProvider is gone from the v6 store, and the preferredModels prop was removed from NewSessionSurface. The next draft's defaultModels reads providers[kind].model, which was never written.

So a composer model pick survives only when the provider is slow to confirm it. On claude-code and codex, which reflect at start, the user re-picks on every new session — and usePersistPickedModel's own doc says it is called "once a selection is known to have been accepted", which is precisely what immediate reflection is.

Persisting from startupSelection when it already carries a confirmed model, with the late promotion kept for adapters that only confirm after the first turn, covers both. The existing "don't erase a newer live selection" reasoning still argues for keeping the late path conditional.

Comment on lines +245 to +249
model: localModel === null ? undefined : (selectedModel ?? undefined),
// Pins the session to the account whose entry was picked; without it the daemon would fall
// back to whichever account happens to be bound.
...(localModel !== null &&
modelOption?.accountId !== undefined && { accountId: modelOption.accountId }),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This guard excludes an explicit reset, but not an untouched draft — and an untouched draft can still pin an account.

selectedAccounts is only ever written by handleModelChange/handleResetModel, so on a draft where the user never opened the model menu, localAccount and localModel are both undefined. localModel !== null is therefore true, and selectedAccountId is undefined, which makes resolveModel skip the account filter entirely (agent-models.ts:67-68) and return the first entry whose id matches.

accountModelOptions flat-maps accounts in pool order, so when two accounts serve the same model id — a direct vendor account and an OpenRouter/gateway relay, the exact case modelChoiceKey exists for, and which default-models.test.ts:113-119 covers — the winner is whichever account happens to come first in the array. That id ships as accountId, and resolveAccount prefers it over providers[kind].activeAccountId (provider-config.ts:90), so the session runs on a different account's key and base URL than the one the user bound. Nothing in the composer looks any different.

Gating on the account the user actually picked keeps the bound account authoritative for untouched drafts, and still pins correctly once a menu entry is chosen (handleModelChange writes next.accountId ?? null).

Suggested change
model: localModel === null ? undefined : (selectedModel ?? undefined),
// Pins the session to the account whose entry was picked; without it the daemon would fall
// back to whichever account happens to be bound.
...(localModel !== null &&
modelOption?.accountId !== undefined && { accountId: modelOption.accountId }),
model: localModel === null ? undefined : (selectedModel ?? undefined),
// Pins the session to the account whose entry was picked; without it the daemon would fall
// back to whichever account happens to be bound.
...(localModel !== null && localAccount != null && { accountId: localAccount }),

"Provider" meant two different things in adjacent UI: the composer's provider
picker chose the *agent*, while the Providers settings page means accounts.
`onOpenProviderSettings: (kind: AgentKind) => void` had both in one signature.

Agent-meaning UI text and client identifiers now say harness —
`selectableHarnesses`, `onHarnessChange`, `lastHarness`, and the composer's menu
label. Account-meaning strings keep "provider", `AgentKind` and every wire and
daemon term are untouched, and `groupModelsByProvider` stays as the one genuine
model-provider use. Same UI/i18n-only discipline already recorded for
Thread/`session`.

Two things this turned up. Translation keys are not typechecked, so the rename
would have silently emptied strings — a test asserting the old menu label is what
caught it. And the store test had hand-copied its storage key, which drifted at
the previous version bump and had quietly turned the malformed-blob test into a
vacuous pass; the key is now exported and imported, and the test verified to fail
against a well-formed blob.
Copilot AI review requested due to automatic review settings August 6, 2026 13:54

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@PeronGH

PeronGH commented Aug 6, 2026

Copy link
Copy Markdown
Member Author

12fde617 — the Provider → Harness rename, kept as the last commit so a broad textual change does not hide logic.

Why

"Provider" meant two different things in adjacent UI. The composer's provider picker chose the agent; the Providers settings page means accounts. onOpenProviderSettings: (kind: AgentKind) => void had both meanings in a single signature.

What moved

Agent-meaning UI text and client identifiers now say harness:

  • i18n: composer.providerharness, and the {provider} interpolations in plugins.discoveryFailed / runtimeMissing and historyImport.panelTitle / emptyHint
  • identifiers: SELECTABLE_PROVIDERSSELECTABLE_HARNESSES, selectableProvidersselectableHarnesses, onProviderChange / onSelectProvideronHarnessChange / onSelectHarness, ModelSelectorMenu's provider prop → harness, new-session's provider state and initialProviderharness / initialHarness, lastProviderlastHarness

Unchanged on purpose: account-meaning strings (the settings nav providers, providers.title, boundNote, noProvider), AgentKind and every wire/daemon term, and groupModelsByProvider — the one genuine model provider use. Same UI/i18n-only discipline already recorded for Thread/session, and the convention is now written into AGENTS.md next to that rule.

Two things the sweep exposed

Translation keys are not typechecked. tsc --build passed clean after the i18n keys were renamed while several call sites still passed the old interpolation names — those strings would have rendered empty at runtime. A test asserting the old menu label is what caught it. Worth knowing for any future i18n rename here: grep the call sites, the compiler will not help.

A test had drifted from the store it tests. new-session-defaults-store.test.ts hand-copied its localStorage key, which went stale at the v6 bump in 8634d2c8 — so "discards malformed persisted selections at the schema boundary" had been asserting against a key nothing reads, passing for the wrong reason. The key is now exported from the store and imported by the test, verified to fail against a well-formed blob before the malformed one was restored.

Deliberately out of scope

historyImportProvider / setHistoryImportProvider in the desktop settings store also means the agent. It was not in the plan, it lives in apps/desktop, and it touches a persisted store key — while the user-visible string it feeds (panelTitle) is already renamed. Left for a follow-up rather than widening this sweep.

Checks

pnpm check:ci exits 0. pnpm test: 2749 passing, with the known packages/host/assets registry loopback failure — outside this change's module graph.

Pure rename: no behaviour moved, so the manual pass still outstanding for this PR is the one described in the previous comment.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ The rename is clean — nothing new is broken by it. The findings from both earlier reviews still reproduce at this HEAD, untouched by this commit, so this isn't an approval.

Reviewed changes — one commit since the prior review (8634d2c12fde61): a UI/i18n-only rename of the agent meaning of "provider" to "harness".

  • Renamed the agent-meaning client identifiersselectableProvidersselectableHarnesses, onProviderChangeonHarnessChange, NewSessionDraft.initialProviderinitialHarness, lastProviderlastHarness, across the composer, the new-session surface, the workbench, and the desktop history-import tab. AgentKind and every wire/daemon term are untouched, and groupModelsByProvider is left as the genuine model-provider use. I read the commit line by line: it is mechanical — no branch, guard, or ordering changed.
  • Renamed three interpolation placeholders and one label{provider}{harness} in plugins.discoveryFailed, plugins.runtimeMissing and historyImport.panelTitle, plus composer.providercomposer.harness, in both locales. Checked each against its call site (plugins-tab.tsx:81,92, history-import-tab.tsx:74, composer-controls.tsx:296); no {provider} placeholder is left in either locale, and no call site passes a name the string no longer has.
  • Bumped the defaults storage key to :v7 and exported it — correct per the frontend rule (bump on any shape change), and it costs released users nothing beyond the :v6 bump already in this PR. The test's hand-copied :v5 had been writing to a key nothing read; with the key imported, the malformed-blob fixture really is rejected — 'unsupported' is not in EffortLevelSchema (low|medium|high|xhigh|max|ultra|ultracode), so that case is no longer vacuous.
  • Recorded the terminology rule in AGENTS.md — provider = account/service, harness = agent, with groupModelsByProvider named as the exception.

I ran the suites this commit touches — new-session-defaults-store, new-session-surface, and the ui plugins tests: 55 tests, all passing.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using Claude Opus𝕏

export interface NewSessionDefaultsState {
/** Provider of the last successful new-session submit; null before the first (→ claude-code). */
lastProvider: AgentKind | null;
lastHarness: AgentKind | null;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The rename stops short of the rest of this file. The doc comment two lines up still says v7 renamed lastProvider to lastHarness (fine — that's history) but line 44's /** Provider of the last successful new-session submit; null before the first (→ claude-code). */ now describes a field named lastHarness, and effortsByProvider is still keyed by AgentKind, i.e. by harness, not by provider. Same for the provider: AgentKind parameters on remember and rememberSelection.

If effortsByProvider is going to be renamed at all, it has to happen inside this same :v7 window — renaming it in a later commit costs users a second persisted-state discard for one logical change. If leaving it is deliberate (e.g. you plan to key efforts by account later), the doc line should say so, because right now the name and its key type disagree.

Technical details
# Finish the harness rename inside the defaults store

## Affected sites
- line 40 — doc comment references the pre-rename name in prose describing current state
- line 44 — `/** Provider of the last successful new-session submit … */` documents `lastHarness`
- lines 25, 45, 102-107 — `effortsByProvider`, keyed by `AgentKind` (a harness)
- lines 49, 54, 59, 65-66, 86, 96 — `provider: AgentKind` parameters and locals

## Required outcome
Every agent-meaning identifier and doc line in this file reads "harness"; if `effortsByProvider` is renamed, the rename lands under the `:v7` bump already in this commit so users pay one discard rather than two.

## Open questions for the human
Is leaving `effortsByProvider` under the old name deliberate (future re-keying by account), or an oversight?

Copilot AI review requested due to automatic review settings August 7, 2026 06:17

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@PeronGH

PeronGH commented Aug 7, 2026

Copy link
Copy Markdown
Member Author

Live account switching, via the existing restart machinery

Four commits (b36c706d..c8c91cab) that undo a constraint this PR introduced.

1608dc43 opened the new-session picker to every account an agent can bind, but scoped the live picker to the session's own account, on the stated reasoning that a running session cannot change account. That holds for in-place switching only — credentials and base URL are injected at spawn — but nothing checked whether respawning was available, and it is. claude-code already closes its process and respawns for an effort transition in/out of max, and codex does the same on an auth change. A cross-account switch is that same operation with a different trigger.

b36c706d — engine refactor, no behaviour change

resumeSession and rewritePrompt each carried their own copy of resolve → tear down → record run → startLive, and disagreed on the third step: resumeSession pushed to record.runs directly while rewritePrompt went through beginRun. Extracted resolveForRecord / launchRun / resumeStrategy and rewrote both on top; beginRun gained a historyId parameter and is now the only writer of a relaunch's run entry.

Kept separate and behaviour-free on purpose — the existing lifecycle tests pass untouched, which is the evidence that the extraction didn't move semantics. Treat any change needed there in review as a signal I got it wrong.

e4e149df — the switch

AgentInput's set-model gains an optional accountId. Additive, and wire 74 is already unshipped on this branch, so no further bump.

SessionLifecycleService.switchModel refuses in this order, all before any teardown:

  1. unknown session → not_found
  2. not running → conflict
  3. same account → delegates to the in-place sendInput path (no restart for an in-account model change)
  4. a turn is active → conflict
  5. no provider transcript → conflict — relaunching would silently start a fresh conversation in place of the one on screen
  6. the agent cannot resume → unsupported

Only then: resolve with { model, config: { accountId } }stopForReplacement → relaunch with the resume strategy. No adapter changes; the old adapter is destroyed and a new one constructed from fresh StartOptions, so opencode's cross-provider rejection in onSetModel is untouched and remains correct for the in-place path, which is now the only one that reaches it.

Two things worth flagging for review, both caught while implementing:

  • The reply had to stay an ack. agent.input registers an ack pending client-side, and session.started resolves only the start map. Routing the switch through startLive's reply channel would have left a successful switch hanging forever. It calls launchRun with replyTo: undefined and the handler sends request.succeeded, so the client contract is unchanged. The cost is that MCP warnings from the re-resolve are dropped — the ack shape has no field for them.
  • The resume check reads the running adapter. Via a new orchestrator.historyCapabilities(sessionId), not the adapter factory. My first attempt asked the factory and silently constructed a throwaway adapter; the test caught it as an off-by-one in the adapter count. Asking the live instance is also the more correct question.

Five tests, including the one that matters most: a refused switch on a resume-less agent leaves the session live and unstopped. Without check 6 the teardown happens first and history.resume fails afterwards — the thread would be gone.

d589b936 — client

Reverts the scoping half of 1608dc43. useSessionModelOptions / accountModelOptionsFor and the sessionModels prop chain are deleted; the live surface takes accountModels?.[active.kind], with the session's own accountId still supplied as currentAccountId so the current entry resolves.

The pick rebinds the account globally. handleModelChange passes the account to persistPickedModel, so withModel moves providers[kind].activeAccountId — the same thing the new-session picker already does. This is deliberate and is what makes the switch outlive the running adapter: a later resume resolves from daemon config, not from the run it is reviving. Since 8634d2c8 made that config the single owner of the model pick, a per-session pin would have created a second owner. Consequence worth knowing: other threads on the old account keep running (credentials are spawn-fixed) but resolve the new account on their next respawn — same semantics as a rebind through Settings, just a new entry point.

Cross-account entries carry a one-line hint, gated on a new accountSwitchRestarts flag so the new-session draft — which also passes a currentAccountId — stays unmarked. The predicate is switchesAccount in agent-models.ts, unit-tested.

c8c91cab — docs

pi's capability row in the matrix was wrong (native/pi/adapter.ts:184 sets list/read/resume/branch all true, the matrix said ). Also records that onSetModel only ever sees same-account switches, and rewrites the SessionInfo.accountId comment that asserted the old constraint.

Verification

pnpm check:ci green. pnpm test is 2755 passed / 1 failed — packages/host/assets registry-client.test.ts times out on loopback, a pre-existing environment failure on my machine unrelated to this change.

Still outstanding, and the gap for this PR overall: the driven check. With two accounts bound to claude-code, start a thread on one, send a turn, then pick the other account's model from the live menu — the thread should restart, resume its transcript, and run the next turn against the new account. Also worth confirming the mid-turn refusal surfaces a visible reason: the engine returns conflict with a message, but I haven't seen how the composer renders it.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

The new cross-account switch works, but the client is never told it happened. beginRun is the only writer of the accountId the UI reads and the only record mutator that skips onChanged, so after a switch the composer still names the old account — which inverts the "restarts this thread" hint it just showed. Details inline on session-record-registry.ts.

Reviewed changes — four commits since the prior review (12fde61c8c91ca), adding live cross-account model switching:

  • SessionLifecycleService.switchModel (lifecycle-service.ts:336-403) — a same-account pick forwards in place via sendInput; a cross-account pick relaunches the session under the same id and resumes the provider transcript. agent.input routes to it only when set-model carries an accountId (request-handler.ts), so the old path is untouched for every other input. The guard order is right and deliberately so: busy / no-transcript / no-resume are all asked before stopForReplacement, and the comment at line 375 says why. I checked the TOCTOU — the whole body runs under the permit-1 session semaphore, so isBusy cannot go stale before the teardown.
  • Three extracted helpersresolveForRecord, launchRun, resumeStrategy, with branch and resume refactored onto them. launchRun becoming the single writer of a run entry is a real simplification.
  • Per-run account attributionbeginRun(sessionId, accountId?, historyId?), latestAccountId (reverse scan, correctly documented as "a rebind between runs is legitimate"), and accountId added to SessionRun / SessionInfo / the list() projection. The schema doc states the contract plainly: "Latest run's account — what the session is talking to now."
  • set-model gains an optional accountId through the whole client stack (control-channelclient-coresdkoperations), additively, so the existing wire floor still holds.
  • The restart hintswitchesAccount + ModelMenuItem + modelSwitchRestarts in both locales. I verified all three render branches of the model menu pass the hint, the string exists in zh-cn.ts (the type source) and en.ts, neither has an interpolation placeholder, and it renders as real text a screen reader reaches.
  • Docs — the new agent-adapter/AGENTS.md bullet ("A live session can change account, but never in place") is exactly the right place for this, and pi's resume column flip to matches the code.

I traced the two things most likely to be wrong here and they are fine: the resumed transcript is not duplicated (the seed's coveredBySeed dedupes by message/tool id), and a failed relaunch is cleaned up properly — startLive's tapError calls discardFailedStart regardless of replyTo, which releases the simulator MCP token, so there is no leak. The engine test suite's live account switching block is genuine coverage, not theatre: it asserts run 2 carries acc_second and that resumedWith.config.apiKey === 'sk-second', and the no-resume case asserts adapters[0].stopped === false to prove the refusal precedes teardown.

Three defects below. All five findings from the three earlier reviews still reproduce at this HEADprobeSecret still takes no service, displayedModel keeps the catalog?.defaultModel arm, accountModelOptions still keys on bindable, workbench.tsx:412 still early-returns before persistPickedModel, and the submit spread still guards on localModel !== null. Not re-raised here; that is why this isn't an approval.

🔴 The relaunch is invisible to the client, and the hint inverts because of it

Anchored inline on session-record-registry.ts. The chain, since it crosses three packages:

beginRun calls persist() but not onChanged() — the only record mutator that doesn't. Every sibling does: register, importRecord, delete, bindHistoryId, setTitleFromContent, setProviderTitle. session.changed is the only revalidation cue for listSessions (client.ts:1217, "the payload is a cue to revalidate through listSessions"), and handleModelChange (workbench.tsx:465-482) never calls mutate() either. launchRun is invoked with replyTo: undefined, and startLive sends session.started only when replyTo !== undefined, so that frame doesn't arrive as a substitute. I checked the whole setModel path down through sdk/operations.ts for a cache invalidation and there is none.

The one path that would have healed it is disarmed by design. bindHistoryId fires onChanged, but it early-returns on run.historyId === historyId (line 127) — and switchModel passes the resumed historyId straight into beginRun, so the new adapter's session-ref announces the id the record already holds and the notify is skipped. The registry's own doc comment at line 141 names this case ("historyId is known up front only when the relaunch resumes a transcript"). So the staleness is not a brief window; it persists until some unrelated session is created, removed, or retitled.

accountId reaches the menu as active?.accountId off that list (shell-frame.tsx:249, desktop-shell.tsx:457). After switching acc_A → acc_B the UI still believes acc_A, so switchesAccount is evaluated against the wrong side and the hint reverses: entries for acc_B — the account the session is now on, where a pick is a harmless in-place set-model — are labelled "Switching account restarts this thread and resumes it", while entries for acc_A, which now genuinely do relaunch, are labelled nothing at all. The second thread restart is the one the feature exists to warn about, and it happens silently. resolveModel(pickable, displayedModel, currentAccountId) is scoped by the same stale id, so a reflected model resolves against the wrong account's entries.

🟠 A session with no recorded account gets no hint, but the daemon still relaunches it

Anchored inline on agent-models.ts. switchesAccount returns false when currentAccountId is undefined, but switchModel compares records.accountId(sessionId) === accountId — and undefined === 'acc_x' is false, so it takes the relaunch branch. A session running on an agent's own CLI login or the legacy providers[kind].apiKey fallback records no account at all, and those are precisely the sessions whose menu is filled with other accounts' models, because accountModelOptions keys on bindable rather than bound (the still-open default-models.ts thread).

The new test pins the undefined case as intentional, but with a draft rationale — "A draft has no running account" — while the same function also serves the live thread through ConversationSurface. The draft is already covered by accountSwitchRestarts defaulting to false, so the undefined arm isn't what protects it.

🟠 Mid-session effort and approval-policy don't survive the relaunch

lifecycle-service.ts:391 — anchored inline. resolveForRecord's override is Pick<StartOptions, 'model' | 'config'>, so the relaunch re-derives everything else from the record (kind, cwd) plus daemon config. The state a user set on the live session via agent.inputset-effort, set-approval-policy — is cached on the LiveSession instance (live-session.ts:145-146, 169-171), which stopForReplacement destroys; the new instance starts empty. snapshot() replays that cache to an attaching client, not across a relaunch.

This is correct for branch and resume, which the user understands as restarts. switchModel is presented as picking a model from a menu, so an unannounced effort reset is a different contract. agent-adapter/AGENTS.md:112 notes StartOptions.effort enters through onSetEffort before onStart, so carrying it is a matter of threading the live values into the override.

ℹ️ Nitpicks

  • The hint promises a restart in cases the daemon refuses outright. accountSwitchRestarts is hardcoded true at the ConversationSurface call site, but switchModel rejects rather than relaunching when the session is busy, has no historyId, or the agent can't resume — grok-build is for resume in the capability matrix yet ✓ (next turn) for set-model, so every cross-account entry in its menu advertises a restart-and-resume that can only produce an error in the banner. All three conditions are knowable client-side (status, historyId and capabilities are already on the session).
  • The relaunch has no rollback. stopForReplacement precedes launchRun, so a relaunch that fails on the new account's credential leaves a thread that was mid-conversation stopped. Cleanup and the error reply are both correct — this is the design's cost, not a bug — but it is reachable from a single menu click, and combined with the missing session.changed the sidebar won't show the stopped status either.
  • Test gaps that match the findings above: nothing covers a launchRun failure after teardown, nothing asserts effort survives (or doesn't) a relaunch, and no test renders accountSwitchRestarts with a multi-account list to assert the hint text — switchesAccount has unit coverage, but the prop-to-rendered-hint path does not.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using Claude Opus𝕏

Comment on lines 151 to 152
this.persist(record);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

beginRun is the only mutator here that persists without notifying — register, importRecord, delete, bindHistoryId, setTitleFromContent and setProviderTitle all call onChanged. It is also the only writer of the accountId this PR just added to the list() projection (line 85), which the constructor's own doc calls "membership and identity" — the category that is supposed to notify. SessionInfoSchema states the contract directly: "Latest run's account — what the session is talking to now."

session.changed is the only revalidation cue for listSessions (client.ts:1217), and the cross-account switch has no other announcement: launchRun is called with replyTo: undefined, so startLive skips session.started, and handleModelChange (workbench.tsx:465-482) never calls mutate(). I followed the setModel path down through sdk/operations.ts looking for an invalidation and there is none.

The path that looks like it would recover is closed off. bindHistoryId does notify, but it early-returns on run.historyId === historyId (line 127) — and switchModel hands the resumed historyId to beginRun, so the new adapter's session-ref reports an id the record already has and the notify never fires. Your comment on line 141 describes exactly this case. Staleness therefore lasts until an unrelated session is created, deleted, or retitled.

Downstream, accountId arrives as active?.accountId (shell-frame.tsx:249, desktop-shell.tsx:457), so switchesAccount compares against the pre-switch account and the restart hint reverses: the new account's entries claim they restart the thread, the old account's entries — the ones that now actually do — say nothing.

Suggested change
this.persist(record);
}
this.persist(record);
// A new run re-points the record's identity projection (`accountId`, `historyId`), so the
// session list must revalidate or clients keep naming the previous account.
this.onChanged(sessionId, 'updated');
}

Comment on lines +40 to +44
return (
currentAccountId !== undefined &&
option.accountId !== undefined &&
option.accountId !== currentAccountId
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

currentAccountId !== undefined suppresses the hint for a session with no recorded account, but the daemon does not agree that the question is inapplicable there — switchModel tests this.records.accountId(sessionId) === accountId (lifecycle-service.ts:353), and undefined === 'acc_x' is false, so it takes the relaunch branch and tears the adapter down.

That state is reachable rather than hypothetical. SessionInfo.accountId comes from resolvedAccountId, which reads config.accountId — written only by accountConfigBundle, and only when an account actually resolves. A session running on an agent's own CLI login (opencode, pi) or on the legacy providers[kind].apiKey fallback records no account at all. Those are exactly the sessions whose menu is populated with other accounts' models, because accountModelOptions keys on bindable rather than bound (the still-open thread on default-models.ts). opencode is for resume, so the relaunch really does proceed.

The new test pins this arm deliberately, but its stated reason is about the draft — "A draft has no running account" — and the draft is already covered by accountSwitchRestarts defaulting to false, since only ConversationSurface sets it. So the undefined check isn't what protects the draft; it only suppresses the hint on the live path where the daemon will act. Matching the daemon's comparison closes the gap, and the doc comment above should then lose "Unknown accounts on either side mean the question doesn't apply" — only an unknown account on the option side makes it inapplicable.

Suggested change
return (
currentAccountId !== undefined &&
option.accountId !== undefined &&
option.accountId !== currentAccountId
);
return option.accountId !== undefined && option.accountId !== currentAccountId;

const launchRun = this.launchRun.bind(this);
const resumeStrategy = this.resumeStrategy.bind(this);
return Effect.gen(function* () {
const resolved = yield* resolveForRecord(record, { model, config: { accountId } });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The override is Pick<StartOptions, 'model' | 'config'>, so everything except the model and the account bundle is re-derived from the record (kind, cwd) and daemon config. Anything the user set on the live session through agent.input is not among those: set-effort and set-approval-policy land on the LiveSession instance (live-session.ts:145-146, 169-171), and stopForReplacement on the next line destroys it. The replacement starts with an empty cache, and snapshot() replays that state to an attaching client, not across a relaunch — so the new adapter comes up on whatever effort the resolver supplies, which for a client-side effort pick is nothing.

For branch and resume this is fine; both read as restarts to the user. switchModel reads as picking a menu entry, so a silent effort or approval-policy reset is a different contract than the hint sets up. Per agent-adapter/AGENTS.md:112, StartOptions.effort enters through onSetEffort before onStart, so the live values can be threaded into the override here — the session is still alive at this point, so they are still readable.

Copilot AI review requested due to automatic review settings August 7, 2026 06:37

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ The e2e fix is correct and it repairs a genuinely broken CI job — one nitpick inline. Every finding from the four earlier reviews still reproduces at this HEAD, untouched by this commit, so this isn't an approval.

Reviewed changes — one commit since the prior review (c8c91ca9ceb386), touching a single file: apps/webview/e2e/browser-smoke.e2e.mts (+14 / −7).

  • Repointed the new-chat isolation seed at the live storage key and fieldlinkcode.workbench.new-session-defaults:v5 / lastProvider:v7 / lastHarness. I traced why this mattered: the old seed landed on a key nothing reads, so the store rehydrated empty, lastHarness stayed null, and the new-session surface fell back to claude-code. The mock host pins claude-code at { status: 'missing' } (dev-mock-host.ts:230), deriveAgentRuntimeCues turns that into { state: 'missing', downloadable: true }, and new-session-surface.tsx:384 makes any cue set sendBlocked — so the subsequent .click() could only sit out its actionability timeout. pi is { status: 'available', source: 'builtin' } and draws no cue, so the new seed really does produce a sendable composer. This e2e runs in CI (ci.yml:215, the Webview Browser Entry job), so the four prior commits were red there.
  • Added a fail-fast diagnostic before the Send click — replaces the opaque actionability timeout with a message naming the storage key and the store module. I checked this doesn't introduce a race: isDisabled() takes an unretried snapshot where .click() used to auto-wait, but nothing in this fixture can flip Send from disabled to enabled after the preceding editor.fill. The locator itself filters on [contenteditable="true"], so fill already gates on disabled={pending || !selected} being false; deriveAgentRuntimeCues yields {} while runtimes load and never produces a cue for pi; and the mock's account pool is [], so accountModelOptions returns {}, bindableSet is undefined, and this PR's new model send-gate never arms.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using Claude Opus𝕏

const webviewDir = fileURLToPath(new URL('..', import.meta.url));
const daemonDir = fileURLToPath(new URL('../../daemon', import.meta.url));
const viteCli = fileURLToPath(new URL('../../bin/vite.js', import.meta.resolve('vite')));
const newSessionDefaultsKey = 'linkcode.workbench.new-session-defaults:v7';

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This key moved twice inside this PR (:v5:v6:v7), and this line is the second place a hand-copied copy drifted. The first was new-session-defaults-store.test.ts, which this PR fixed structurally — by importing the constant, with a comment saying the mismatch had turned a test vacuous. Here the same obligation is only a code comment, so the next bump silently breaks this e2e again and the sole signal is a red CI job.

Technical details
# Pin the e2e's storage key to `NEW_SESSION_DEFAULTS_STORAGE_KEY` at compile time

## Affected sites
- `apps/webview/e2e/browser-smoke.e2e.mts:18``newSessionDefaultsKey` is a hand-copied string literal; nothing fails if it drifts from the store.
- `apps/webview/e2e/browser-smoke.e2e.mts:76``lastHarness` is likewise hand-copied. `PersistedNewSessionDefaultsSchema` is `.partial()`, so a stale field name is dropped by `safeParse` rather than rejected, which is exactly why this drift is silent.
- `packages/client/workbench/src/surface/new-session-defaults-store.ts:19` — the constant exists and is exported for precisely this reason, but is not re-exported from the package barrel (`src/index.ts` covers `./surface/*` selectively and omits this module).

## Required outcome
- A future bump of the storage key or a rename of a persisted field fails `pnpm typecheck` rather than only the webview e2e job. `apps/webview/e2e/tsconfig.json` is already a root `tsconfig.json` reference, so the e2e file is covered by the solution build.

## Suggested approach
A runtime import is not viable — the e2e runs under plain `node` type-stripping and the workbench barrel pulls in React, zustand and CSS. A type-only pin costs nothing at runtime:

```ts
const newSessionDefaultsKey: typeof import('@linkcode/workbench').NEW_SESSION_DEFAULTS_STORAGE_KEY =
  'linkcode.workbench.new-session-defaults:v7';
```

`export const NEW_SESSION_DEFAULTS_STORAGE_KEY = '…'` already infers the string literal type, so the assignment stops compiling the moment the key changes. This needs one line added to `packages/client/workbench/src/index.ts` to put the module on the barrel (the package's `AGENTS.md` forbids consumers deep-importing other paths).

## Open questions for the human
- Worth doing the same for the persisted field names, or is the code comment enough there? A `keyof` pin would need `PersistedNewSessionDefaults` exported too, which is more surface than the key alone.

Copilot AI review requested due to automatic review settings August 7, 2026 14:07

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@pullfrog

pullfrog Bot commented Aug 7, 2026

Copy link
Copy Markdown

Your Claude subscription has hit its usage limit. It resets at 4:20pm (UTC). Re-trigger Pullfrog after the reset, or add an ANTHROPIC_API_KEY repo secret — Pullfrog routes around an exhausted subscription automatically when one is present.

Add repo secret → · Model settings → · Setup docs → · Ask in Discord →

Pullfrog  | Rerun failed job ➔View workflow run | via Pullfrog | Using Claude Opus𝕏

@PeronGH

PeronGH commented Aug 7, 2026

Copy link
Copy Markdown
Member Author

Follow-up: two account surfaces, and a CI fix

Four commits since the last update (c8c91cab..00b75fec).

9ceb3869 — the red Webview Browser Entry job

Not caused by the switch work; broken earlier in this PR and worth recording. verifyNewChatIsolation seeds localStorage to open a new chat on pi, then clicks Send. 8634d2c8 bumped the defaults store v5 → v6 and 12fde617 bumped it to v7 while renaming lastProviderlastHarness. The seed is schema-validated and a mismatched blob is discarded silently, so the new chat fell back to claude-code, whose mock runtime is missing — that raises a runtime cue, sendBlocked stays true, and the click retried until timeout.

Fixed by seeding the current key and field (verified against c430eba8, the last green run, where the key really was :v5/lastProvider). The test now also throws a named error if Send is disabled when it gets there, so the next rename fails with a sentence instead of a 30s timeout on a mystery disabled button.

The design change

Discussion on this PR established that the previous model was wrong. Binding was single-valued (activeAccountId), so enabling DeepSeek for claude-code visibly disabled the Claude OAuth account, even though the composer never actually read the binding and offered both regardless. Settings was describing a conflict that did not exist.

The replacement is two separate surfaces:

  1. Enabled — which accounts an agent offers in its model menu. Multi-valued.
  2. Default — which account an agent falls back to when a session names none. Single, and now explicitly managed rather than a side effect of the last pick.

That combination only works with a third piece, because a session that pins an account at start still re-resolves on every respawn. So the record now remembers its own pick.

47c820e8 — a thread keeps its own model and account

SessionRun gains model beside the accountId it already recorded, and resolveForRecord defaults its override to the newest run's pair instead of falling through to daemon config. Cold resume, prompt rewrite, and the cross-account switch all keep the thread where it is, independent of the default.

This reverses the global-rebind decision from earlier in this branch. That was the right call while no explicit default surface existed — there was nowhere else for a pick to live. Now there is, and silently overwriting it from a composer click would defeat the point.

beginRun's growing tail of optional positionals became one SessionRun-shaped object, with a guard so an absent field never writes undefined into the persisted record.

ab457388 — enabled set, explicit default

ProviderConfig.enabledAccountIds, absent meaning every bindable account — existing configs migrate silently and a newly added account is offered without a Settings visit. accountModelOptions intersects bindable with that list; one function feeds both pickers, so the live and new-session menus follow together.

Composer picks no longer call persistPickedModel — both sites and the hook are gone.

Settings: each agent row in the account dialog now has a switch (show in this agent's menu) and a star (use as the fallback). Only the star-holding row edits the default model, and it sources from the account's own picked models rather than the curated table. withBinding split into withAccountEnabled / withDefaultAccount; the bound / bound-elsewhere / no-provider statuses collapse into enabled/disabled/default.

Two behaviours worth a look in review, both tested:

  • the first disable materializes the enabled list from what is bindable now, so an account added later joins the list rather than being silently excluded by an older snapshot;
  • disabling the account that serves the default also clears the default, since leaving it would keep routing unpinned sessions to an account just removed from the menu.

00b75fec — docs, plus one gate fix

While documenting it: the resolver's "no model selected" gate keyed off activeAccountId !== undefined, i.e. "a default exists". That missed a session pinning an account on an agent with no default — it would quietly start on the adapter's own model instead of refusing. It now keys off whether an account actually resolved.

Decisions taken, flag if wrong

  • Disabling an account does not kill existing threads. They carry their own pick on the record and keep running; disabling only removes the account from menus. Deleting it remains the destructive path.
  • The new-session composer no longer remembers your last model across drafts — it starts from the agent's default each time. That memory previously lived in the daemon config write we removed. If it should come back it belongs in the local new-session-defaults store next to effortsByProvider, not in the shared default.

Verification

pnpm check:ci green; pnpm test 2760 passed / 1 failed, the failure being packages/host/assets registry-client.test.ts timing out on loopback — a pre-existing environment failure on my machine, untouched here. All CI checks green.

Still outstanding: the driven check with two real accounts — start a thread on one, send a turn, pick the other account's model, confirm it restarts, resumes, and reports the new account; then confirm a mid-turn switch is refused visibly, and that the Settings enable/default toggles behave.

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.

2 participants