🤖 fix: keep gateway IDs and record intent for VS Code webview model picks - #4834
Merged
Merged
Conversation
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
This was referenced Sep 27, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
In the VS Code webview, a model pick now behaves like a desktop pick. An explicit gateway model such as
openrouter:openai/gpt-5stays selected and is sent as picked, instead of being collapsed to the directopenai:model. The pick is also recorded as a deliberate selection, so a sub-agent's metadata refresh no longer overwrites an unsent pick. Sends still never persist AI settings; that is the next PR for #4781.Background
#4781 turns on persistence of explicit webview picks by reusing the desktop's send-time path. That path needs two things from the pick itself, both missing in the webview (Codex findings on #4778):
normalizeSelectedModel), notnormalizeToCanonical;markAiSelectionIntent) that marks the pick as deliberate.This PR adds both, with no persistence change, so it can land on its own.
Implementation
vscode/src/webview/ChatComposer.tsx:onModelChangefollows the desktopChatInput.setPreferredModel(workspace variant):normalizeSelectedModel,ensureModelInSettings, thenmarkAiSelectionIntent(workspaceId, "model", …), the model key and the per-agent cache (🤖 fix: keep a VS Code webview model pick across agent switches #4799).storedModel) is gateway-preserving too, as in the desktop composer, so a gateway pick stays selected in the dropdown and in model cycling.ThinkingProvider.The intent is desktop code (
src/browser/utils/aiSelectionIntent.ts). The webview's only new behavior is the desktop's own:seedWorkspaceLocalStorageFromBackendkeeps a sub-agent's unsent pick (keepsUnsentPick).Validation
vscode/src/webview/App.test.tsx(TestBridge). Both failed onmain:openrouter:openai/gpt-5. Onmainit sentopenai:gpt-5.mainthe send carried the seededopenai:gpt-5.6-terra.beforeEach, so picks from one test cannot leak into the next.bun test ./vscode/src/: 53 pass.xumChatViewbundle in a page that plays the extension host. The yellow panel shows each postedworkspace.sendMessageoptions.mainbuild vs this branch, same steps:Gateway pick,
main(collapsed toopenai:gpt-5), 800px:Gateway pick, this branch (
openrouter:openai/gpt-5), 800px and 390px:Sub-agent pick, then a workspace-list refresh, then send. On
mainthe pick is lost (Terra is sent); on this branch Sonnet 5 stays selected and is sent:Every send still carries
skipAiSettingsPersistence: true, and noupdateAgentAISettingscall is made.Risks
Low, webview only. What the webview sends changes only for explicit gateway models, which now match what the desktop sends for the same pick.
Refs #4781
📋 Implementation Plan (both PRs for #4781)
#4781 plan: persist explicit AI-setting picks from the VS Code webview
Result
Yes, this fits in 2 small stacked PRs (
gh stack), about +37 net product LoC in total:Approach: reuse the desktop's actual persistence path, which is send-time persistence. The desktop never writes on pick, so the webview will not either. No new
updateAgentAISettingscalls, no webview write queue, no backend product changes.The rule (PR 2): a webview send persists AI settings only when all of these hold:
selectedWorkspace.aiis present).getAiSelectionIntentForSendOptionsreturns an intent).In every other case the send keeps
skipAiSettingsPersistence: true, exactly as today.Why send-time persistence, not pick-time
updateAgentAISettingsThe coordinator's direction names
updateAgentAISettings. The code shows that the desktop's persistence path is the send, and that the send reaches the same backend write.workspace.updateAgentAISettingsonly when it creates a workspace. Model and thinking picks write onlylocalStorageand in-memory selection intent (#3968: picks stay local until the next send).src/browser/features/ChatInput/useCreationWorkspace.ts:662;ChatInput/index.tsx:722-782;ThinkingContext.tsx:195-218maybePersistAISettingsFromOptions→persistWorkspaceAISettingsForAgent. That is the same writeupdateAgentAISettingsuses: per-agent bucket plus selected agent,normalizeSelectedModel(gateway-preserving), no thinking clamp. It is best-effort and runs before queueing, refusals andsendMessage's return.src/node/services/workspaceService.ts:11165-11200,:11119,:11286-11335,:13015-13017taskAiPins) only through the send'saiSelectionIntent, which is committed at acceptance or enqueue.updateAgentAISettingsnever touches pins.workspaceService.ts:12623-12628,:12896-12917,:11237-11248,:11406updateAgentAISettingsrequiresmodelandthinkingLeveltogether, so it cannot write one field on its own either.src/common/orpc/schemas/api.ts:1558-1566;workspaceAiSettings.ts:12-22markAiSelectionIntent,getAiSelectionIntentForSendOptionsandconsumeAiSelectionIntent. Tokens are bound to a value and scoped per workspace and agent. They are consumed on success only, and a newer pick survives.src/browser/utils/aiSelectionIntent.ts:20-150A pick-time write from the webview would need its own write serializer, gateway handling and companion-field handling, and it would still skip pins. Those are the round-1 findings on #4778. Consequence, stated plainly: a pick that is never sent is never persisted. The desktop behaves the same way.
Rejected alternative: pick-time writes (≈ +60–80 net product LoC)
The alternative is to call
updateAgentAISettingson each explicit pick, through a per-workspace latest-wins queue in the webview. It is a webview-only variant of a write the desktop does not make. It cannot updatetaskAiPins, so the Codex P2 on sub-agent selection intent comes back. It must send a companion field anyway, so it has the same semantics as the send path, with more code. It also adds a second writer that races the send's own persistence.Design
Webview send today (for reference)
vscode/src/webview/ChatComposer.tsx:306-317builds{...getSendOptionsFromStorage(ws), agentId, skipAiSettingsPersistence: true, ...(policyFallbackModel ? {model: policyFallbackModel} : {})}.getSendOptionsFromStoragealready sends the gateway-preserving model and the raw stored thinking level (src/browser/utils/messages/sendOptions.ts:51-63).ThinkingProvideralready records thinking intent (ThinkingContext.tsx:202).normalizeToCanonical(ChatComposer.tsx:229-251).PR 1: pick semantics (sends still skip persistence)
ChatComposer.onModelChangebecomes the desktop'ssetPreferredModelfor the workspace variant:normalizeSelectedModel(model)replacesnormalizeToCanonical(model). The gateway route is kept in the model key, the per-agent cache, theensureModelInSettingsinput (as on desktop,index.tsx:730,744) and therefore in the send.markAiSelectionIntent(props.workspaceId, "model", selectedModel), as the desktop does atindex.tsx:749.setPreferredModelfor the local key.setWorkspaceModelWithOriginis not needed. Its origin is read only by the desktop's context-switch warning and Auto routing, and the webview has neither: its auto-routing flags are always false (sendOptions.ts:91-97, and no webview control sets them).ChatComposer.tsx:249-251.User-visible effects, both desktop parity:
openrouter:openai/gpt-5is sent as picked.keepsUnsentPickinWorkspaceContext.tsx:231-240.PR 2: the persistence gate and ordering
In
ChatComposer.tsx:In
App.tsx: passaiSettingsLoaded={selectedWorkspace?.ai != null}toChatComposer(App.tsx:723-741). The composer does not receiveaitoday.Checked: with no enforced policy, or before
policy.getanswers,storedModelAllowedistrue(src/common/utils/policy/modelPolicy.ts:38-41,useModelsFromSettings.ts:94-95,170-178). The gate therefore does not block the common case.storedModelAllowedalso covers #4811's "no allowed listed model" case, where the stored model is policy-excluded and is sent unchanged: that model is never persisted either.What a persisting send writes (decision D2, default: desktop parity)
{model, thinkingLevel, reasoningMode}and the workspace's selected agent, exactly as a desktop send does. The schema requires model and thinking together.WorkspaceContext.tsx:193-197). A model-only pick therefore writes the snapshot thinking level, which can be older than a change another client made since.WorkspaceContext.tsx:218-229) gets a bucket whose companion field is the webview's resolved default.selectedWorkspace.ai, which App keeps current. It adds a branch for a multi-client edge case that the desktop accepts.Ordering guarantee
Within one webview, a later explicit pick is never overwritten by an earlier persisting send:
markAiSelectionIntentreplaces the field's pending token.sendMessagereturns. When the server replies, that send's write has landed or been skipped, and only then can the next persisting send start.skip: trueand keeps its tokens. The next send after the earlier one resolves writes the latest pick. With theisSendingguard, only a remount (switching workspaces and back while the send is pending) can produce this overlap.unknown, and later sends do not persist until reload. The webview link rejects on abort, postsorpcCanceland drops late responses (createVscodeOrpcLink.ts:260-264,360-374), so the outcome cannot be known.{success:false}is a server reply: the entry is cleared, the tokens are kept, and the next send writes the pick again. This is idempotent.Out of scope, same as desktop multi-window: ordering across different clients, such as the desktop plus a webview, or two VS Code windows.
Sub-agent lock
The lock covers the agent identity. Model and thinking picks on a child are allowed and pinned, as on desktop (#4459).
useAgent(), resolved byresolvePersistedAgentIdthroughworkspaceMetaFallback(App.tsx:63-74).markAiSelectionIntentreads (WorkspaceContext.tsx:207-216), so the intent scopes match.aiSelectionIntent. Child execution stays locked toagentTypeeither way (agentResolution.ts:223-228).How the #4778 round-1 findings are addressed
WorkspaceModeAISyncis mounted (#4778) and the per-agent cache is updated on pick (#4799).aiSelectionIntentand consumes it on success, asChatInput/index.tsx:2469,2561does.normalizeSelectedModel. The backend also normalizes with it on persist.aiis loaded, and a reverted pick no longer matches its value-bound token.PR split, tests and quality gates
Gate 0 (before PR 1): rebase on the latest
origin/main, and confirm that the cited lines inChatComposer.tsx,App.tsxandaiSelectionIntent.tsstill match. If #4765–#4813 moved, re-anchor the plan before coding.PR 1: "use the desktop's model-pick semantics in the VS Code webview"
vscode/src/webview/ChatComposer.tsx,vscode/src/webview/App.test.tsx.resetAiSelectionIntentForTests()to theApp.test.tsxbeforeEach. It is an existing seam. Intents live at module level and currently leak between tests; 🤖 fix: keep a VS Code webview model pick across agent switches #4799's test leaves a Plan pick pending.providers.getConfigwithopenrouter: {apiKeySet, isEnabled, isConfigured, models: ["openai/gpt-5"]}(useModelsFromSettings.ts:42-60lists it asopenrouter:openai/gpt-5), pick that entry, send →options.model === "openrouter:openai/gpt-5"normalizeToCanonicalsends the directopenai:IDparentWorkspaceId), pick Sonnet 5, emitworkspacesagain with an unchangedai, send → model containssonnetupdateAgentAISettings), and the 🤖 fix: fall back to an allowed model when admin policy excludes the VS Code webview's selection #4811/🤖 fix: load app and providers config into the VS Code webview with host-side redaction #4813 policy tests.skipAiSettingsPersistence: true; there are noupdateAgentAISettingsposts.bun test ./vscode/src/andmake static-checkare green, and PR 1 dogfood D-1 is captured (see below). Then open PR 1.PR 2: "persist explicit AI-setting picks from the VS Code webview"
vscode/src/webview/ChatComposer.tsx,vscode/src/webview/App.tsx,vscode/src/webview/App.test.tsx,src/node/services/workspaceService.aiSettings.test.ts.bridge.answer("workspace.sendMessage", { success: true, data: {} })) before it ends. Otherwisein-flightleaks into later tests onWORKSPACE.id.skip: true, noaiSelectionIntentconfig.getConfigfloorhighfor the picked model, stored thinkinglow,policy.getunanswered ornull; pick → send →skip: false,aiSelectionIntent: {model: true}, the picked model,thinkingLevel: "low"(unclamped companion); answer success; send again →skip: trueskip: false, Opus, intent{model: true}medium, open[data-thinking-selector-trigger], click therole=optionwitharia-label="High", send →skip: false, intent{thinkingLevel: true},thinkingLevel: "high"WORKSPACEwithoutai, explicit pick, send →skip: truerenderWithPolicy(allow only terra) plusproviders.getConfig, explicit thinking pick, send → fallback model,skip: true, no intentagentId: "plan",agentType: "exec"; pick → send →agentId: "exec",skip: false, intent present; the toggle stays disabledskip: false, unanswered). Enter with new text → still 1sendMessage. Switch to ws-2 and back (remount), pick B, send 2 →skip: true. Answer both. Send 3 →skip: false, model BorpcResponse ok:false(throw) → pick → send →skip: true. (b) Pick → send → answer{success: false, error}→ send again →skip: falsewith the same intentworkspaceService.aiSettings.test.ts: fills the gap next to the pins-only case (:267-291)updateAgentAISettingscalls on any path.skip: trueleaves backend buckets untouched (10).bun test ./vscode/src/,bun test src/node/services/workspaceService.aiSettings.test.ts(Bun 1.3.5 from~/.bun/bin) andmake static-checkare green, and PR 2 dogfood D-2 is captured. Then open PR 2 stacked on PR 1.isSendingguard inChatComposer.tsx≈ +33;App.tsxprop ≈ +2).skip: truefor sub-agents. That removes the pins and lock surface; test 7 then assertsskip: true. Report to the coordinator before shrinking further.Dogfooding (served bundle, agent-browser)
The #4740 harness was ad hoc (nothing committed), and
vscode/out/is gitignored. All artifacts go under/tmp/ws25-*.bun run --cwd vscode compile, which emitsvscode/out/xumChatView.{js,css}andkatex/. If esbuild is missing, runmake -C vscode node_modules/.installedfirst. Copyvscode/out/*to/tmp/ws25-harness/./tmp/ws25-harness/index.html(sketch below). It stubsacquireVsCodeApi, plays the host, and shows each postedworkspace.sendMessageoptions subset in a visible<pre>, so screenshots and video carry the evidence.python3 -m http.server 8765 -d /tmp/ws25-harnessas a monitored background task. Save its PID and kill only that PID afterwards.agent-browser:open about:blank, thenrecord start /tmp/ws25-evidence/<pr>.webm, thenopen http://localhost:8765/?scenario=…. Usesnapshot -i→click @reffor the model dropdown, thinking selector and Send. Check the payload withagent-browser eval 'JSON.stringify(window.__sends())'. Takescreenshotat 800 px and 390 px widths. Runtab listafterrecord startand close stale tabs. Check the decoded frames.attach_filefor review. Post them on the PR with/usr/local/bin/gh pr comment <n> --body-file … --attach …, because the miseghis too old for--attach.Scenarios:
openrouter:openai/gpt-5entry and send. The payload shows the gateway ID andskip: true. Also run the unmodified main build first as a before/after pair.window.__emitWorkspaces(). The selector still shows the pick.skip: true.skip: falseplusaiSelectionIntent.model. A second send →skip: true.skip: true.agentId: exec, intent present.sendMode=hold, pick A, send,__select('ws-2'),__select('ws-1'), pick B, send →skip: true.__release()all, then send →skip: falsewith B.sendMode=throw: the next send →skip: true.KEEP_SANDBOX=1 make dev-server-sandbox DEV_SERVER_SANDBOX_ARGS="--clean-providers --clean-projects"(dev-server-sandbox skill). NoteBACKEND_PORTand the sandbox root.RPCLinkfetch client asvscode/src/api/client.ts, replay the exactsendMessageinputs captured in D-2(b) and D-2(a). The second one uses a different model.config.jsonfor the workspace. The persisting payload writesaiSettingsByAgent.<agent> = {model: "openrouter:openai/gpt-5", thinkingLevel: <raw>}andagentId. The skip payload changes nothing. The stream may fail without provider keys; persistence runs before it.Harness page sketch
window.acquireVsCodeApi = () => ({ postMessage, getState, setState }).postMessagepushes ontowindow.__posted, re-renders<pre id="sends">, and replies throughwindow.postMessage(...).WebviewToExtensionMessageinvscode/src/webview/protocol.tsfor the exact type), postconnectionStatus(mode: "api"), thenworkspaces, thensetSelectedWorkspace, thenchatEvent {type: "caught-up"}.ws-1, withai.aiSettingsByAgentplan/exec buckets;ws-2;parentWorkspaceId,agentId: "plan"andagentType: "exec".orpcCallanswers ({type: "orpcResponse", requestId, ok: true, kind: "value", value}):policy.get:null, or an enforced policy for (d).providers.getConfig: anthropic and openai configured, plus openrouter withmodels: ["openai/gpt-5"].config.getConfig:{minThinkingLevelByModel: {...}}.agents.list:[].workspace.sendMessage, depending onsendMode:ok:{success: true, data: {}}.hold: queue the request forwindow.__release().throw:{ok: false, error: "harness transport error"}.window.__sends()(the options subset:agentId,model,thinkingLevel,skipAiSettingsPersistence,aiSelectionIntent),window.__select(id)andwindow.__emitWorkspaces().Risks and residuals
unknownstays set after a transient errorDecisions adopted (the coordinator can override)
updateAgentAISettingswrites (evidence above).Refsand no closing keyword. After PR 2 merges, close the issue by hand with a residual comment. File follow-up issues only for residuals the coordinator wants tracked (D2 knob, D3).Out of scope
workspace.setActiveTurnThinkingLevelis not on the webview allowlist; the call is best-effort and swallowed).Generated with
xum• Model:anthropic:claude-opus-5-5• Thinking:high• Cost:$8.14