Skip to content

🤖 fix: persist explicit AI-setting picks from the VS Code webview - #4836

Merged
ThomasK33 merged 2 commits into
mainfrom
fix/webview-persist-explicit-picks
Sep 27, 2026
Merged

ThomasK33 merged 2 commits into
mainfrom
fix/webview-persist-explicit-picks

Conversation

@ThomasK33

@ThomasK33 ThomasK33 commented Sep 27, 2026 •

Copy link
Copy Markdown
Member

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:

  1. The workspace's own settings are loaded.
  2. The admin policy has loaded and allows the stored model, so the 🤖 fix: fall back to an allowed model when admin policy excludes the VS Code webview's selection #4811 fallback model is never saved.
  3. No earlier saving send for the workspace is still unresolved.
  4. The send carries an explicit pick that is still current.

Every other send keeps skipAiSettingsPersistence: true, as before.

Background

Webview persistence was turned off in #4765 and #4778, because the webview's pick-time updateAgentAISettings writes 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 calls updateAgentAISettings only when it creates a workspace. Every other save happens at send time: WorkspaceService.sendMessage → maybePersistAISettingsFromOptions → persistWorkspaceAISettingsForAgent, which is the same write updateAgentAISettings uses. aiSelectionIntent pins 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.
  • When it does, the send carries skipAiSettingsPersistence: false and aiSelectionIntent. After a successful send, consumeAiSelectionIntent clears only the tokens that send carried, so a pick made while the send was in flight stays pending.
  • aiSettingsLoaded (passed from App.tsx), the policy having loaded (PolicyContext.loading, review round 1) and storedModelAllowed gate this. An assertion makes sure a policy fallback model can never be persisted.
  • Write order. A module-level map allows at most one saving send per workspace to be unresolved. Module level is needed because the composer remounts per workspace.
    • The backend saves before sendMessage returns, so a server reply, success or failure, settles the entry.
    • A send that overlaps an unresolved saving send does not save. Its pick stays pending, and the next send writes it.
    • A saving send that ends without a server reply (the 30 s abort or a transport error) may still land later. Later sends for that workspace then do not save until the webview reloads (fail closed). The picks still apply to the turns.
  • Enter no longer starts a second send while one is in flight. The Send button was already disabled then, and the desktop composer behaves the same way.

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

  • Tests written first in vscode/src/webview/App.test.tsx (TestBridge).
    • Failing before this change:
      • an explicit model pick is saved once, and the companion thinking level low is not raised to Opus's floor;
      • of several rapid picks, only the last is saved;
      • an explicit thinking pick is saved;
      • a locked sub-agent (restamped agentId: plan, agentType: exec) saves into exec with intent;
      • ordering: Enter during an in-flight send does nothing, a remount-overlapping send does not save, and the next send writes the latest pick;
      • a pick is retried after a server-reported failure.
    • Guards, each proven by deleting its condition and watching the test fail:
      • no pick: no save;
      • no loaded settings: no save;
      • policy fallback: no save;
      • policy still loading: no save (review round 1);
      • in-flight map;
      • isSending;
      • unknown outcome: no save until reload.
  • src/node/services/workspaceService.aiSettings.test.ts: skipAiSettingsPersistence leaves agentId and aiSettingsByAgent unchanged. 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.
  • Served-bundle dogfood: the built xumChatView bundle in a page that plays the extension host. The yellow panel lists each posted workspace.sendMessage options.

Seeded send (not saved), explicit gateway pick (saved with its gateway ID), then a send with no new pick (not saved), 800px and 390px:

Explicit gateway pick saved once, 800px

Explicit gateway pick saved once, 390px

Admin policy excludes the stored model: the fallback is sent and nothing is saved, even after an explicit thinking pick:

Policy fallback not saved, 800px

Policy fallback not saved, 390px

Locked sub-agent: the toggle is disabled, and the pick is saved into exec with intent:

Locked sub-agent pick, 800px

Locked sub-agent pick, 390px

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:

Ordering, 800px

A saving send that fails in transport: the next pick is not saved until reload:

Unknown outcome, 800px

Recording (seeded send, gateway pick, then the ordering sequence):

pr2-persist-and-ordering.webm
  • Real-backend replay against make dev-server-sandbox (no provider keys; the save runs before the stream). The exact options posted above were replayed with workspace.sendMessage, and config.json was read after each:
initial          {}
after persisting {"agentId":"exec","aiSettingsByAgent":{"exec":{"model":"openrouter:openai/gpt-5","thinkingLevel":"medium"}}}
after skip       {"agentId":"exec","aiSettingsByAgent":{"exec":{"model":"openrouter:openai/gpt-5","thinkingLevel":"medium"}}}

The skip payload used plan / Opus / low and changed nothing.

Risks

Medium, webview only; there are no backend product changes. Residuals, all shared with the desktop:

  • On a main workspace, a model-only pick saves the per-load snapshot of the thinking level, which can be older than a change another client made since.
  • Tokens are consumed on a successful send even if the best-effort backend write failed.
  • There is no ordering across different clients.

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:

    • PR 1 (≈ +2 net product LoC): webview model picks use the desktop's pick semantics (gateway-preserving model ID, recorded selection intent). Sends still never persist.
    • PR 2 (≈ +35 net product LoC): webview sends persist explicit picks through the desktop's send-time path, with a gate and an ordering guarantee. Adds one backend test.
  • 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 updateAgentAISettings calls, no webview write queue, no backend product changes.

  • The rule (PR 2): a webview send persists AI settings only when all of these hold:

    1. The workspace's settings are loaded (selectedWorkspace.ai is present).
    2. The stored model is allowed by admin policy, so the 🤖 fix: fall back to an allowed model when admin policy excludes the VS Code webview's selection #4811 fallback model is never persisted.
    3. No earlier persisting send for this workspace is unresolved in this webview.
    4. The send carries an explicit, still-current pick for the send's agent (getAiSelectionIntentForSendOptions returns an intent).

    In every other case the send keeps skipAiSettingsPersistence: true, exactly as today.

Why send-time persistence, not pick-time updateAgentAISettings

The 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.

Fact (origin/main 40649ca) Where
The desktop renderer calls workspace.updateAgentAISettings only when it creates a workspace. Model and thinking picks write only localStorage and 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-218
Ordinary desktop sends persist through maybePersistAISettingsFromOptions → persistWorkspaceAISettingsForAgent. That is the same write updateAgentAISettings uses: per-agent bucket plus selected agent, normalizeSelectedModel (gateway-preserving), no thinking clamp. It is best-effort and runs before queueing, refusals and sendMessage's return. src/node/services/workspaceService.ts:11165-11200, :11119, :11286-11335, :13015-13017
Sub-agent picks pin (taskAiPins) only through the send's aiSelectionIntent, which is committed at acceptance or enqueue. updateAgentAISettings never touches pins. workspaceService.ts:12623-12628, :12896-12917, :11237-11248, :11406
updateAgentAISettings requires model and thinkingLevel together, so it cannot write one field on its own either. src/common/orpc/schemas/api.ts:1558-1566; workspaceAiSettings.ts:12-22
The desktop's intent helpers already exist and are shared: markAiSelectionIntent, getAiSelectionIntentForSendOptions and consumeAiSelectionIntent. 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-150

A 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 updateAgentAISettings on 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 update taskAiPins, 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-317 builds {...getSendOptionsFromStorage(ws), agentId, skipAiSettingsPersistence: true, ...(policyFallbackModel ? {model: policyFallbackModel} : {})}.

  • getSendOptionsFromStorage already sends the gateway-preserving model and the raw stored thinking level (src/browser/utils/messages/sendOptions.ts:51-63).
  • The shared ThinkingProvider already records thinking intent (ThinkingContext.tsx:202).
  • The model pick does not record intent, and it collapses gateway IDs with normalizeToCanonical (ChatComposer.tsx:229-251).

PR 1: pick semantics (sends still skip persistence)

ChatComposer.onModelChange becomes the desktop's setPreferredModel for the workspace variant:

  • normalizeSelectedModel(model) replaces normalizeToCanonical(model). The gateway route is kept in the model key, the per-agent cache, the ensureModelInSettings input (as on desktop, index.tsx:730,744) and therefore in the send.
  • Add markAiSelectionIntent(props.workspaceId, "model", selectedModel), as the desktop does at index.tsx:749.
  • Everything else stays: the per-agent cache write from 🤖 fix: keep a VS Code webview model pick across agent switches #4799, and setPreferredModel for the local key.
    • setWorkspaceModelWithOrigin is 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).
  • Update the 🤖 fix: VS Code webview first send overwrites workspace AI settings with webview defaults #4755 comment at ChatComposer.tsx:249-251.

User-visible effects, both desktop parity:

  • An explicit gateway model such as openrouter:openai/gpt-5 is sent as picked.
  • A sub-agent's unsent pick survives a metadata refresh, through keepsUnsentPick in WorkspaceContext.tsx:231-240.

PR 2: the persistence gate and ordering

In ChatComposer.tsx:

// Module scope: the composer remounts per workspace (App.tsx key={selectedWorkspaceId}).
// "in-flight": a persisting send has not returned. "unknown": one ended without a server
// result (30 s abort or transport error), so its write may still land later. Fail closed
// until the webview reloads rather than let it overwrite a later pick (#4781).
const aiPersistenceByWorkspace = new Map<string, "in-flight" | "unknown">();

// onSend: add `isSending` to the first guard (desktop handleSend exits on !canSend,
// index.tsx:2017; today Enter bypasses the disabled button).
const baseOptions = { ...getSendOptionsFromStorage(props.workspaceId), agentId };
const mayPersist =
  props.aiSettingsLoaded && storedModelAllowed && !aiPersistenceByWorkspace.has(props.workspaceId);
const selection = getAiSelectionIntentForSendOptions(props.workspaceId, agentId, {
  ...baseOptions,
  skipAiSettingsPersistence: !mayPersist, // the desktop wrapper returns no intent when skipping
});
const persist = selection.intent !== undefined;
assert(!persist || policyFallbackModel === null, "a policy fallback model must never be persisted");
const options = {
  ...baseOptions,
  skipAiSettingsPersistence: !persist,
  ...(persist ? { aiSelectionIntent: selection.intent } : {}),
  ...(policyFallbackModel ? { model: policyFallbackModel } : {}),
};
if (persist) aiPersistenceByWorkspace.set(props.workspaceId, "in-flight");
// Any result (success or {success:false}): delete the entry, because the server replied
// after its awaited persist. On success: consumeAiSelectionIntent(ws, agentId,
// selection.attachedTokens). catch: set "unknown". No `finally` delete: it would erase "unknown".

In App.tsx: pass aiSettingsLoaded={selectedWorkspace?.ai != null} to ChatComposer (App.tsx:723-741). The composer does not receive ai today.

Checked: with no enforced policy, or before policy.get answers, storedModelAllowed is true (src/common/utils/policy/modelPolicy.ts:38-41, useModelsFromSettings.ts:94-95,170-178). The gate therefore does not block the common case. storedModelAllowed also 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)

  • The backend writes the send's agent bucket {model, thinkingLevel, reasoningMode} and the workspace's selected agent, exactly as a desktop send does. The schema requires model and thinking together.
  • Seeded values are never persisted on their own: without an explicit pick, the send skips persistence.
  • The field the user did not pick is written with the value the turn actually runs with.
  • Residuals, all identical to desktop behavior:
    • (a) On a main workspace the webview snapshots settings once per load (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.
    • (b) A workspace with no buckets at all (seeding returns early, WorkspaceContext.tsx:218-229) gets a bucket whose companion field is the webview's resolved default.
  • Optional stricter knob (≈ +5 LoC, not recommended by default): skip persistence when the un-picked companion differs from the latest backend bucket in 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:

  1. Picks write nothing to the backend, so rapid picks cannot race each other. The last pick is what the next send carries. markAiSelectionIntent replaces the field's pending token.
  2. At most one persisting send per workspace is unresolved at any time (the module map). It survives the composer's per-workspace remount.
  3. The backend persists before sendMessage returns. When the server replies, that send's write has landed or been skipped, and only then can the next persisting send start.
  4. A send that overlaps an unresolved persisting send goes with skip: true and keeps its tokens. The next send after the earlier one resolves writes the latest pick. With the isSending guard, only a remount (switching workspaces and back while the send is pending) can produce this overlap.
  5. A persisting send that ends without a server result marks the workspace unknown, and later sends do not persist until reload. The webview link rejects on abort, posts orpcCancel and drops late responses (createVscodeOrpcLink.ts:260-264,360-374), so the outcome cannot be known.
  6. {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).

  • The send already uses the locked agent from useAgent(), resolved by resolvePersistedAgentId through workspaceMetaFallback (App.tsx:63-74).
  • Seeding writes the same resolved agent into the agent key that markAiSelectionIntent reads (WorkspaceContext.tsx:207-216), so the intent scopes match.
  • Any mismatch, for example a pick made before hydration, finds no intent and does not persist (fail closed). Nothing is ever written into another agent's bucket.
  • The backend persists into the sent agent's bucket and pins through aiSelectionIntent. Child execution stays locked to agentType either way (agentResolution.ts:223-228).

How the #4778 round-1 findings are addressed

Finding Resolution
P1 stale agent settings on toggle Already fixed: WorkspaceModeAISync is mounted (#4778) and the per-agent cache is updated on pick (#4799).
P2 no selection intent for sub-agents PR 1 marks model intent (thinking already marked). PR 2 attaches aiSelectionIntent and consumes it on success, as ChatInput/index.tsx:2469,2561 does.
P2 gateway route collapsed PR 1: normalizeSelectedModel. The backend also normalizes with it on persist.
P2 client-clamped thinking persisted No client write at all. The send carries the raw stored level and the backend does not clamp on persist.
P1 unserialized writes No pick-time writes. Send-level ordering as described above.
P2 pre-hydration picks reverted Unchanged (#4778 decision). They can never persist wrongly: no persistence before ai is 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 in ChatComposer.tsx, App.tsx and aiSelectionIntent.ts still 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"

  • Files: vscode/src/webview/ChatComposer.tsx, vscode/src/webview/App.test.tsx.
  • Test hygiene: add resetAiSelectionIntentForTests() to the App.test.tsx beforeEach. 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.
  • Red tests (TestBridge; each fails on main):
Test Setup → assertion Fails on main because
"sends a gateway-routed model pick with its gateway ID" Answer providers.getConfig with openrouter: {apiKeySet, isEnabled, isConfigured, models: ["openai/gpt-5"]} (useModelsFromSettings.ts:42-60 lists it as openrouter:openai/gpt-5), pick that entry, send → options.model === "openrouter:openai/gpt-5" normalizeToCanonical sends the direct openai: ID
"keeps a sub-agent's unsent model pick across a metadata refresh" Child fixture (parentWorkspaceId), pick Sonnet 5, emit workspaces again with an unchanged ai, send → model contains sonnet The reseed overwrites the pick (no pending intent)

PR 2: "persist explicit AI-setting picks from the VS Code webview"

  • Files: vscode/src/webview/ChatComposer.tsx, vscode/src/webview/App.tsx, vscode/src/webview/App.test.tsx, src/node/services/workspaceService.aiSettings.test.ts.
  • Test hygiene:
    • Every test that makes a persisting send answers it (bridge.answer("workspace.sendMessage", { success: true, data: {} })) before it ends. Otherwise in-flight leaks into later tests on WORKSPACE.id.
    • The unknown-outcome test uses its own workspace ID. No new test seam.
  • Tests. "Red" means the test fails on PR 1's head. "Guard" means it passes on PR 1 but fails if its branch of the gate is removed (prove each guard once by locally deleting that condition).
# Test Setup → assertion Type
1 "does not persist a send without an explicit pick" Loaded main workspace (plan/terra/high), send → skip: true, no aiSelectionIntent Guard
2 "persists an explicit model pick once, at the next send" config.getConfig floor high for the picked model, stored thinking low, policy.get unanswered or null; pick → send → skip: false, aiSelectionIntent: {model: true}, the picked model, thinkingLevel: "low" (unclamped companion); answer success; send again → skip: true Red
3 "persists only the last of several rapid picks" Pick Sonnet, then Opus, send → one send, skip: false, Opus, intent {model: true} Red
4 "persists an explicit thinking pick as selected" Seed thinking medium, open [data-thinking-selector-trigger], click the role=option with aria-label="High", send → skip: false, intent {thinkingLevel: true}, thinkingLevel: "high" Red
5 "never persists for a workspace without loaded AI settings" Extend the #4765 test: WORKSPACE without ai, explicit pick, send → skip: true Guard
6 "never persists the admin-policy fallback model" renderWithPolicy (allow only terra) plus providers.getConfig, explicit thinking pick, send → fallback model, skip: true, no intent Guard
7 "persists a locked sub-agent's pick only into its locked agent" Child agentId: "plan", agentType: "exec"; pick → send → agentId: "exec", skip: false, intent present; the toggle stays disabled Red
8 "keeps one persisting send per workspace in flight; the next send writes the latest pick" Two workspaces. Pick A → send 1 (skip: false, unanswered). Enter with new text → still 1 sendMessage. Switch to ws-2 and back (remount), pick B, send 2 → skip: true. Answer both. Send 3 → skip: false, model B Red
9 "stops persisting for a workspace after a send ends without a server result, but retries after a server error" (a) Own workspace ID: pick → send → orpcResponse ok:false (throw) → pick → send → skip: true. (b) Pick → send → answer {success: false, error} → send again → skip: false with the same intent (b) Red; (a) Guard
10 Backend: "sendMessage with skipAiSettingsPersistence leaves aiSettingsByAgent and agentId unchanged" workspaceService.aiSettings.test.ts: fills the gap next to the pins-only case (:267-291) Guard (currently untested)
  • Acceptance criteria:
    • AC1: sends without an explicit pick never persist (1, 5).
    • AC2: an explicit model or thinking pick persists once, with the gateway-preserving ID and the raw thinking level (2, 3, 4).
    • AC3: the 🤖 fix: fall back to an allowed model when admin policy excludes the VS Code webview's selection #4811 fallback, or a policy-excluded stored model, never persists (6).
    • AC4: sub-agents persist only into the locked agent, with pins intent attached (7).
    • AC5: the ordering rules hold (3, 8, 9), and Enter no longer sends while a send is in flight.
    • AC6: the webview makes no updateAgentAISettings calls on any path.
    • AC7: skip: true leaves backend buckets untouched (10).
  • Gate 2: bun test ./vscode/src/, bun test src/node/services/workspaceService.aiSettings.test.ts (Bun 1.3.5 from ~/.bun/bin) and make static-check are green, and PR 2 dogfood D-2 is captured. Then open PR 2 stacked on PR 1.
  • Net product LoC: ≈ +35 (module map + gate + consume/catch + isSending guard in ChatComposer.tsx ≈ +33; App.tsx prop ≈ +2).
  • Pre-agreed scope reduction if PR 2 stops converging (drop a sub-change after its 2nd finding): persist on main workspaces only, and keep skip: true for sub-agents. That removes the pins and lock surface; test 7 then asserts skip: 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-*.

  1. Build: bun run --cwd vscode compile, which emits vscode/out/xumChatView.{js,css} and katex/. If esbuild is missing, run make -C vscode node_modules/.installed first. Copy vscode/out/* to /tmp/ws25-harness/.
  2. Write /tmp/ws25-harness/index.html (sketch below). It stubs acquireVsCodeApi, plays the host, and shows each posted workspace.sendMessage options subset in a visible <pre>, so screenshots and video carry the evidence.
  3. Serve: python3 -m http.server 8765 -d /tmp/ws25-harness as a monitored background task. Save its PID and kill only that PID afterwards.
  4. Drive with agent-browser: open about:blank, then record start /tmp/ws25-evidence/<pr>.webm, then open http://localhost:8765/?scenario=…. Use snapshot -i → click @ref for the model dropdown, thinking selector and Send. Check the payload with agent-browser eval 'JSON.stringify(window.__sends())'. Take screenshot at 800 px and 390 px widths. Run tab list after record start and close stale tabs. Check the decoded frames.
  5. Attach the screenshots with attach_file for review. Post them on the PR with /usr/local/bin/gh pr comment <n> --body-file … --attach …, because the mise gh is too old for --attach.

Scenarios:

  • D-1 (PR 1):
    • Pick the openrouter:openai/gpt-5 entry and send. The payload shows the gateway ID and skip: true. Also run the unmodified main build first as a before/after pair.
    • Child workspace: pick, then window.__emitWorkspaces(). The selector still shows the pick.
    • Screenshots at 800 and 390 px.
  • D-2 (PR 2):
    • (a) Seeded-only send → skip: true.
    • (b) Explicit gateway model pick → skip: false plus aiSelectionIntent.model. A second send → skip: true.
    • (c) Thinking pick → the raw level is persisted.
    • (d) Policy fallback status line plus a thinking pick → skip: true.
    • (e) Locked child: disabled toggle, agentId: exec, intent present.
    • (f) Ordering: harness sendMode=hold, pick A, send, __select('ws-2'), __select('ws-1'), pick B, send → skip: true. __release() all, then send → skip: false with B.
    • (g) sendMode=throw: the next send → skip: true.
    • Take screenshots of (b), (d) and (e) at 800 and 390 px, and one WebM covering (b) and (f).
  • Real-backend replay (PR 2):
    1. Start KEEP_SANDBOX=1 make dev-server-sandbox DEV_SERVER_SANDBOX_ARGS="--clean-providers --clean-projects" (dev-server-sandbox skill). Note BACKEND_PORT and the sandbox root.
    2. Add a temp git repo as a project and create a workspace, through the sandbox UI with agent-browser or through oRPC.
    3. Using a short bun script that uses the same RPCLink fetch client as vscode/src/api/client.ts, replay the exact sendMessage inputs captured in D-2(b) and D-2(a). The second one uses a different model.
    4. Diff config.json for the workspace. The persisting payload writes aiSettingsByAgent.<agent> = {model: "openrouter:openai/gpt-5", thinkingLevel: <raw>} and agentId. The skip payload changes nothing. The stream may fail without provider keys; persistence runs before it.
Harness page sketch
  • window.acquireVsCodeApi = () => ({ postMessage, getState, setState }). postMessage pushes onto window.__posted, re-renders <pre id="sends">, and replies through window.postMessage(...).
  • On the webview's startup message (check WebviewToExtensionMessage in vscode/src/webview/protocol.ts for the exact type), post connectionStatus (mode: "api"), then workspaces, then setSelectedWorkspace, then chatEvent {type: "caught-up"}.
    • The workspace set depends on the scenario:
      • main ws-1, with ai.aiSettingsByAgent plan/exec buckets;
      • ws-2;
      • a child, with parentWorkspaceId, agentId: "plan" and agentType: "exec".
  • orpcCall answers ({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 with models: ["openai/gpt-5"].
    • config.getConfig: {minThinkingLevelByModel: {...}}.
    • agents.list: [].
    • workspace.sendMessage, depending on sendMode:
      • ok: {success: true, data: {}}.
      • hold: queue the request for window.__release().
      • throw: {ok: false, error: "harness transport error"}.
  • Helpers: window.__sends() (the options subset: agentId, model, thinkingLevel, skipAiSettingsPersistence, aiSelectionIntent), window.__select(id) and window.__emitWorkspaces().

Risks and residuals

Risk Handling
Review churn: persistence drew 6 findings in #4778 round 1 Each finding is mapped above. Reuse the desktop helpers only. Budget: 6 assessments per PR, including the final independent check. Use the pre-agreed PR 2 scope reduction.
Stale companion field on a main workspace (D2 residual a) Desktop parity (#3968 snapshot rule). The optional knob is available if the coordinator wants it.
unknown stays set after a transient error Persistence stays off for that workspace until the webview reloads. Turns still use the picks; this is the pre-#4781 behavior. Documented in a code comment as a deliberate trade-off.
Backend persistence is best-effort Tokens are consumed on a successful send even if the config write failed server-side. Desktop parity.
Cross-client ordering (desktop plus webview, two VS Code windows) Last writer wins at the backend. Out of scope, same as desktop multi-window.
Enter no longer sends while a send is in flight Desktop parity. The typed text stays in the box.
Pre-hydration picks They can be dropped (fail closed), never persisted wrongly. The #4778 decision stands.

Decisions adopted (the coordinator can override)

  • D1: send-time persistence through the desktop's path, instead of pick-time updateAgentAISettings writes (evidence above).
  • D2: a bucket is written as a unit (desktop parity). The strict companion knob is off.
  • D3: agent-only toggles never persist. When a persisting send writes a pick, the agent it was sent with is saved too, as on desktop. Issue 🤖 feat: persist explicit AI-setting changes from the VS Code webview #4781 also mentions agent choice; persisting agent-only toggles would be a follow-up if wanted.
  • D4: after an unknown outcome, fail closed until the webview reloads.
  • Issue hygiene: both PR bodies reference 🤖 feat: persist explicit AI-setting changes from the VS Code webview #4781 with Refs and 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

  • The mid-turn thinking nudge (workspace.setActiveTurnThinkingLevel is not on the webview allowlist; the call is best-effort and swallowed).
  • Disabling controls before hydration.
  • Backend versioning or CAS for settings writes.
  • Changing the webview's 30 s send timeout.

Generated with xum • Model: anthropic:claude-opus-5-5 • Thinking: high • Cost: $8.14

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 27, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-09-27T05:08:03.114508Z 1746b41 New commits
🔒 Security Review ✅ Completed 2026-09-27T05:09:58.340618Z 1746b41 New commits
ℹ️ 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" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 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".

Comment thread vscode/src/webview/ChatComposer.tsx
Comment thread vscode/src/webview/ChatComposer.tsx
@ThomasK33
ThomasK33 added this pull request to the merge queue Sep 27, 2026
Merged via the queue into main with commit 32873c8 Sep 27, 2026
30 of 31 checks passed
@ThomasK33
ThomasK33 deleted the fix/webview-persist-explicit-picks branch September 27, 2026 05:48
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.

🤖 feat: persist explicit AI-setting changes from the VS Code webview

1 participant