Przeglądaj źródła

fix(v2): preserve session variant on variant-less model pins (#1189)

Internal callers pin the session's current model without a variant
opinion (orchestrator-wake pins, task-message, same-model fallback
steps). The v2 shim translated every pin into a variant-less
session.switchModel, which the host treats as an explicit reset to the
default variant — resetting the user's reasoning-effort selection
(wake-variant-reset regression, 11 sessions 9/5-9/14).

A variant-less pin that already matches the session's current model
(same provider + id, read via session.get at delivery time so a
mid-flight user variant change wins) now skips switchModel entirely.
Explicit variants (including 'default') and cross-model pins still
switch; hosts without session.get or with a failing get keep the legacy
variant-free switch. Wake-side modelVariant threading landed separately
in 59507cb6; this is the class-level guard for every other path.
Gold John King 3 dni temu
rodzic
commit
fc365f96c6

+ 10 - 0
docs/opencode-v2-compatibility.md

@@ -616,6 +616,16 @@ How it differs from the v1 path:
   The wake model pin carries the session model's variant as the v2-only
   `modelVariant` argument, so `switchModel` preserves the reasoning-effort
   setting instead of resetting it to the host default.
+- **Variant-preserving skip (shim-level guard):** a variant-less model pin
+  that already matches the session's current model (same provider + id,
+  read via `session.get` at delivery time) is treated as "continue on this
+  model": the shim skips `switchModel` entirely instead of resetting the
+  variant to default. This covers every internal caller that pins the
+  current model without a variant opinion (wake pins, task-message,
+  same-model fallback steps) even when the pin's source lost the variant.
+  Explicit variants (including `default` via `modelVariant`) and
+  cross-model pins still switch. Hosts without `session.get`, or a failing
+  `get`, keep the legacy variant-free switch.
 - **Fingerprint:** children-only (id + outcome + tracked status + update
   evidence); the two-wake no-progress cap still bounds cost.
 

+ 110 - 0
src/v2/client-shim.test.ts

@@ -899,6 +899,116 @@ describe('v2 client shim promptAsync model-switch hardening (#1125)', () => {
     expect(Object.hasOwn(model, 'variant')).toBe(false);
   });
 
+  test('promptAsync preserves the current variant when a variant-less pin matches the session model', async () => {
+    const seq: Array<{ m: string; i: unknown }> = [];
+    const promptAsync = makePromptAsync({
+      get: async () => ({
+        model: { providerID: 'test', id: 'model-a', variant: 'max' },
+      }),
+      switchModel: async (i: unknown) => {
+        seq.push({ m: 'switchModel', i });
+      },
+      prompt: async (i: unknown) => {
+        seq.push({ m: 'prompt', i });
+        return {};
+      },
+    } as never);
+    const res = (await promptAsync({
+      path: { id: 'ses_1' },
+      body: {
+        model: { providerID: 'test', modelID: 'model-a' },
+        parts: [{ type: 'text', text: 'wake reminder' }],
+      },
+    })) as { switched?: boolean };
+    // The pin names the model the session already runs on; asserting no
+    // variant must not reset the user's reasoning-effort selection.
+    expect(seq.map((c) => c.m)).toEqual(['prompt']);
+    // Session is on the requested model — the switch claim stays truthful.
+    expect(res.switched).toBe(true);
+  });
+
+  test('promptAsync explicit modelVariant still switches when the pin matches the session model', async () => {
+    const seq: Array<{ m: string; i: unknown }> = [];
+    const promptAsync = makePromptAsync({
+      get: async () => ({
+        model: { providerID: 'test', id: 'model-a', variant: 'max' },
+      }),
+      switchModel: async (i: unknown) => {
+        seq.push({ m: 'switchModel', i });
+      },
+      prompt: async (i: unknown) => {
+        seq.push({ m: 'prompt', i });
+        return {};
+      },
+    } as never);
+    await promptAsync({
+      path: { id: 'ses_1' },
+      body: {
+        model: { providerID: 'test', modelID: 'model-a' },
+        parts: [{ type: 'text', text: 'fallback replay' }],
+      },
+      modelVariant: 'default',
+    });
+    expect(seq.map((c) => c.m)).toEqual(['switchModel', 'prompt']);
+    const switchCall = seq[0] as { i: { model: unknown } };
+    expect(switchCall.i.model).toEqual({
+      id: 'model-a',
+      providerID: 'test',
+      variant: 'default',
+    });
+  });
+
+  test('promptAsync still switches when the pin targets a different model than the session', async () => {
+    const seq: Array<{ m: string; i: unknown }> = [];
+    const promptAsync = makePromptAsync({
+      get: async () => ({
+        model: { providerID: 'test', id: 'model-b', variant: 'max' },
+      }),
+      switchModel: async (i: unknown) => {
+        seq.push({ m: 'switchModel', i });
+      },
+      prompt: async (i: unknown) => {
+        seq.push({ m: 'prompt', i });
+        return {};
+      },
+    } as never);
+    await promptAsync({
+      path: { id: 'ses_1' },
+      body: {
+        model: { providerID: 'test', modelID: 'model-a' },
+        parts: [{ type: 'text', text: 'fallback replay' }],
+      },
+    });
+    expect(seq.map((c) => c.m)).toEqual(['switchModel', 'prompt']);
+    const switchCall = seq[0] as { i: { model: unknown } };
+    expect(switchCall.i.model).toEqual({ id: 'model-a', providerID: 'test' });
+  });
+
+  test('promptAsync degrades to the variant-free switch when session get fails', async () => {
+    const seq: Array<{ m: string; i: unknown }> = [];
+    const promptAsync = makePromptAsync({
+      get: async () => {
+        throw new Error('session.get failed');
+      },
+      switchModel: async (i: unknown) => {
+        seq.push({ m: 'switchModel', i });
+      },
+      prompt: async (i: unknown) => {
+        seq.push({ m: 'prompt', i });
+        return {};
+      },
+    } as never);
+    const res = (await promptAsync({
+      path: { id: 'ses_1' },
+      body: {
+        model: { providerID: 'test', modelID: 'model-a' },
+        parts: [{ type: 'text', text: 'wake reminder' }],
+      },
+    })) as { switched?: boolean };
+    expect(seq.map((c) => c.m)).toEqual(['switchModel', 'prompt']);
+    expect(res.switched).toBe(true);
+  });
+
   test('promptAsync modelVariant without a body model does not switch models', async () => {
     const seq: Array<{ m: string; i: unknown }> = [];
     const promptAsync = makePromptAsync({

+ 39 - 4
src/v2/client-shim.ts

@@ -397,11 +397,46 @@ export function buildPluginInput(
           // reasoning-effort variant (v1 prompt bodies carry no variant
           // slot). A non-empty string overrides the ref's variant so
           // switchModel does not reset it to the host default.
-          const switchRef =
+          const explicitVariant =
             typeof args?.modelVariant === 'string' && args.modelVariant
-              ? { ...ref, variant: args.modelVariant }
-              : ref;
-          if (s.switchModel) {
+              ? args.modelVariant
+              : undefined;
+          const switchRef = explicitVariant
+            ? { ...ref, variant: explicitVariant }
+            : ref;
+          // Variant preservation: internal callers (orchestrator-wake,
+          // task-message, foreground-fallback) pin the session's CURRENT
+          // model without a variant opinion. Re-asserting such a pin via
+          // switchModel resets the host-side reasoning-effort variant to
+          // default (the wake-variant-reset regression). A variant-less
+          // ref that already matches the session model is therefore a
+          // no-op pin: skip the switch entirely, read at delivery time so
+          // a mid-flight user variant change wins. Explicit variants
+          // (including 'default') and cross-model pins still switch.
+          // Hosts without session.get (or failing it) keep the legacy
+          // variant-free switch behavior.
+          let skipSwitch = false;
+          if (!explicitVariant && s.get) {
+            try {
+              const info = await s.get({ sessionID: sessionIDOf(args) });
+              const current = isRecord(info) ? info.model : undefined;
+              skipSwitch =
+                isRecord(current) &&
+                current.providerID === switchRef.providerID &&
+                current.id === switchRef.id;
+            } catch {
+              // Fail-soft: cannot prove the pin matches — switch as before.
+            }
+          }
+          if (skipSwitch) {
+            // The session already runs the pinned model (with its current
+            // variant); the switch claim stays truthful without a call.
+            switched = true;
+            log(
+              '[v2][shim] pin matches current model; variant-preserving skip of session.switchModel',
+              { id: sessionIDOf(args), model: switchRef },
+            );
+          } else if (s.switchModel) {
             // The prompt delivery is the load-bearing action: a failed
             // model switch degrades to steering on the CURRENT model
             // (logged here; `switched: false` on the result) instead of

Plik diff jest za duży
+ 0 - 0
src/v2/codemap.md


Niektóre pliki nie zostały wyświetlone z powodu dużej ilości zmienionych plików