🤖 fix: persist explicit AI-setting picks from the VS Code webview - #4836
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. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: ff49fa117b
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
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
The VS Code webview now saves a user's explicit model and thinking picks to the workspace, the same way the desktop does. Picks write nothing when made. The next send carries them, and the backend saves them. A send persists AI settings only when all of these hold:
Every other send keeps
skipAiSettingsPersistence: true, as before.Background
Webview persistence was turned off in #4765 and #4778, because the webview's pick-time
updateAgentAISettingswrites drew six review findings: selection intent, gateway IDs, clamped thinking, write order, stale agent settings and pre-hydration picks. The desktop renderer never writes on pick. It callsupdateAgentAISettingsonly when it creates a workspace. Every other save happens at send time:WorkspaceService.sendMessage→maybePersistAISettingsFromOptions→persistWorkspaceAISettingsForAgent, which is the same writeupdateAgentAISettingsuses.aiSelectionIntentpins a sub-agent's picks. #4834 made webview picks keep gateway IDs and record selection intent. This PR switches the send path on.Implementation
vscode/src/webview/ChatComposer.tsx, send path only. It uses the desktop's own helpers:getAiSelectionIntentForSendOptions(workspaceId, agentId, …)decides whether an explicit, still-matching pick exists for the send's agent. Tokens are bound to a value, so a seeded or reverted value never counts as a pick.skipAiSettingsPersistence: falseandaiSelectionIntent. After a successful send,consumeAiSelectionIntentclears only the tokens that send carried, so a pick made while the send was in flight stays pending.aiSettingsLoaded(passed fromApp.tsx), the policy having loaded (PolicyContext.loading, review round 1) andstoredModelAllowedgate this. An assertion makes sure a policy fallback model can never be persisted.sendMessagereturns, so a server reply, success or failure, settles the entry.What gets saved is exactly what a desktop send saves: the send's agent bucket
{model, thinkingLevel, reasoningMode}and the selected agent. The field the user did not change is written with the value the turn ran with. The thinking level is saved as selected; the backend applies floors per turn. Agent-only toggles do not save.Validation
vscode/src/webview/App.test.tsx(TestBridge).lowis not raised to Opus's floor;agentId: plan,agentType: exec) saves intoexecwith intent;isSending;src/node/services/workspaceService.aiSettings.test.ts:skipAiSettingsPersistenceleavesagentIdandaiSettingsByAgentunchanged. This row was missing, and it fails if the backend's skip check is removed.bun test ./vscode/src/: 64 pass.bun test src/node/services/workspaceService.aiSettings.test.ts: 32 pass.xumChatViewbundle in a page that plays the extension host. The yellow panel lists each postedworkspace.sendMessageoptions.Seeded send (not saved), explicit gateway pick (saved with its gateway ID), then a send with no new pick (not saved), 800px and 390px:
Admin policy excludes the stored model: the fallback is sent and nothing is saved, even after an explicit thinking pick:
Locked sub-agent: the toggle is disabled, and the pick is saved into
execwith intent:Ordering, with the host holding send 1. Pick A, send 1 saves. Enter during the flight does nothing. Switch workspaces and back, pick B: send 2 does not save. After send 1 resolves, send 3 saves B:
A saving send that fails in transport: the next pick is not saved until reload:
Recording (seeded send, gateway pick, then the ordering sequence):
pr2-persist-and-ordering.webm
make dev-server-sandbox(no provider keys; the save runs before the stream). The exact options posted above were replayed withworkspace.sendMessage, andconfig.jsonwas read after each:The skip payload used
plan/ Opus /lowand changed nothing.Risks
Medium, webview only; there are no backend product changes. Residuals, all shared with the desktop:
A transient transport error stops webview saving for that workspace until reload. That is a deliberate fail-closed choice.
Fixes #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