Просмотр исходного кода

fix(v2): harden foreground-fallback model switch against #1125 gaps

Refs #1125. Two gaps in the promptAsync shim's model-switch path:

- switchModel failures propagated into foreground-fallback's catch-all
  "busy session" branch: the session was aborted, retried after
  REPROMPT_DELAY_MS, and the second rejection killed the fallback with
  no delivery. The shim now catches switchModel failures itself, logs
  them, and continues with the prompt (steer on the current model) —
  the delivery is the load-bearing action.

- hosts without session.switchModel silently steered on the current
  model while the manager still set sessionModel, fired
  onSessionModelChanged, logged "switched to fallback model" and
  toasted. promptBody now declares `modelSwitch: 'required'` on v2
  only; the shim rejects such calls with a typed
  V2SwitchModelUnavailableError (foreground-fallback rethrows it past
  the busy-session misclassification), confirms real switches with
  `switched: true` on the ack, and the manager skips the switch-claim
  bookkeeping entirely when `switched === false`.

The typed rejection is scoped to modelSwitch:'required' callers:
orchestrator-wake pins the session's current model through the same
body field and must keep steering on switchModel-less hosts.

Gate: typecheck + check:ci clean; 2591 tests pass; cache-safety
suites green with zero snapshot updates; v1 promptBody bytes and
fallback flow unchanged.
GoldJohnKing 6 дней назад
Родитель
Сommit
574a7d1c30

+ 7 - 1
docs/opencode-v2-compatibility.md

@@ -335,7 +335,13 @@ so delegated subagents can run.
 
 When the foreground model hits a rate limit, the plugin switches the
 session's model (`session.switchModel`) and steers the re-prompt through
-`delivery: "steer"`.
+`delivery: "steer"`. A failing `switchModel` call degrades honestly: the
+re-prompt is still delivered (on the current model) and the plugin's logs
+record that no switch happened — the fallback chain is not aborted. On
+hosts without `session.switchModel`, the fallback replay is rejected with
+a clear error instead of silently replaying on the model that just failed
+(other prompt callers, like the orchestrator-wake scheduler, only pin the
+current model and keep steering).
 
 ## Limitations
 

+ 163 - 0
src/hooks/foreground-fallback/index.test.ts

@@ -882,6 +882,169 @@ describe('ForegroundFallbackManager session.error', () => {
     expect(mocks.promptAsync).toHaveBeenCalledTimes(2);
   });
 
+  test('v1 promptBody carries no v2 modelSwitch flag and still claims the switch', async () => {
+    // v1 byte-identity: the shim-only `modelSwitch` arg must appear ONLY
+    // on v2 hosts, and a v1-shaped result (no `switched` key) keeps the
+    // model-switch bookkeeping.
+    const { mocks } = createMockClient();
+    const onModelChanged = mock();
+    const mgr = new ForegroundFallbackManager(
+      makeChains(),
+      true,
+      { directory: '/test' } as any,
+      3,
+      undefined,
+      onModelChanged,
+    );
+
+    await mgr.handleEvent({
+      type: 'message.updated',
+      properties: {
+        info: {
+          sessionID: 'sess-1',
+          providerID: 'anthropic',
+          modelID: 'claude-opus-4-5',
+          role: 'assistant',
+        },
+      },
+    });
+    await mgr.handleEvent({
+      type: 'session.error',
+      properties: {
+        sessionID: 'sess-1',
+        error: { message: 'Rate limit exceeded' },
+      },
+    });
+
+    expect(mocks.promptAsync).toHaveBeenCalledTimes(1);
+    const call = mocks.promptAsync.mock.calls[0] as [Record<string, unknown>];
+    expect('modelSwitch' in call[0]).toBe(false);
+    expect(onModelChanged).toHaveBeenCalledTimes(1);
+    expect(onModelChanged).toHaveBeenCalledWith('sess-1', 'openai/gpt-4o');
+  });
+
+  test('v2 host promptBody requests a required model switch', async () => {
+    const { mocks } = createMockClient();
+    const mgr = new ForegroundFallbackManager(makeChains(), true, {
+      directory: '/test',
+      hostFlavor: 'v2',
+    } as any);
+
+    await mgr.handleEvent({
+      type: 'message.updated',
+      properties: {
+        info: {
+          sessionID: 'sess-v2',
+          providerID: 'anthropic',
+          modelID: 'claude-opus-4-5',
+          role: 'assistant',
+        },
+      },
+    });
+    await mgr.handleEvent({
+      type: 'session.error',
+      properties: {
+        sessionID: 'sess-v2',
+        error: { message: 'Rate limit exceeded' },
+      },
+    });
+
+    const call = mocks.promptAsync.mock.calls[0] as [Record<string, unknown>];
+    expect(call[0].modelSwitch).toBe('required');
+  });
+
+  test('switched:false result (v2 switch failure) skips the switch claim', async () => {
+    // The v2 shim degrades a failed switchModel into a prompt delivered on
+    // the CURRENT model; the manager must not record a model switch that
+    // did not happen (sessionModel feeds chain descent, the callback
+    // migrates provider accounting, the toast claims a switch).
+    const { mocks } = createMockClient({
+      promptAsyncImpl: async () => ({ switched: false }),
+    });
+    const onModelChanged = mock();
+    const showToast = mock(async () => ({}));
+    const mgr = new ForegroundFallbackManager(
+      makeChains(),
+      true,
+      { directory: '/test', hostFlavor: 'v2', client: { tui: { showToast } } },
+      3,
+      undefined,
+      onModelChanged,
+    );
+
+    await mgr.handleEvent({
+      type: 'message.updated',
+      properties: {
+        info: {
+          sessionID: 'sess-degrade',
+          providerID: 'anthropic',
+          modelID: 'claude-opus-4-5',
+          role: 'assistant',
+        },
+      },
+    });
+    await mgr.handleEvent({
+      type: 'session.error',
+      properties: {
+        sessionID: 'sess-degrade',
+        error: { message: 'Rate limit exceeded' },
+      },
+    });
+
+    // The prompt was delivered exactly once — no busy-session abort dance.
+    expect(mocks.promptAsync).toHaveBeenCalledTimes(1);
+    expect(mocks.abort).not.toHaveBeenCalled();
+    expect(onModelChanged).not.toHaveBeenCalled();
+    expect(showToast).not.toHaveBeenCalled();
+  });
+
+  test('typed no-switchModel rejection is not treated as a busy session', async () => {
+    // Hosts without session.switchModel reject the required-switch replay
+    // with V2SwitchModelUnavailableError; aborting + retrying cannot fix a
+    // missing host capability, so the error must surface after ONE call.
+    const switchErr = new Error(
+      '[v2] host provides no session.switchModel; cannot switch model for fallback prompt',
+    );
+    switchErr.name = 'V2SwitchModelUnavailableError';
+    const { mocks } = createMockClient({
+      promptAsyncImpl: async () => {
+        throw switchErr;
+      },
+    });
+    const onModelChanged = mock();
+    const mgr = new ForegroundFallbackManager(
+      makeChains(),
+      true,
+      { directory: '/test', hostFlavor: 'v2' } as any,
+      3,
+      undefined,
+      onModelChanged,
+    );
+
+    await mgr.handleEvent({
+      type: 'message.updated',
+      properties: {
+        info: {
+          sessionID: 'sess-noswitch',
+          providerID: 'anthropic',
+          modelID: 'claude-opus-4-5',
+          role: 'assistant',
+        },
+      },
+    });
+    await mgr.handleEvent({
+      type: 'session.error',
+      properties: {
+        sessionID: 'sess-noswitch',
+        error: { message: 'Rate limit exceeded' },
+      },
+    });
+
+    expect(mocks.promptAsync).toHaveBeenCalledTimes(1);
+    expect(mocks.abort).not.toHaveBeenCalled();
+    expect(onModelChanged).not.toHaveBeenCalled();
+  });
+
   test('shows a toast when fallback switches models on a transient error', async () => {
     const { mocks } = createMockClient();
     const showToast = mock(async () => ({}));

+ 47 - 3
src/hooks/foreground-fallback/index.ts

@@ -18,6 +18,7 @@
  */
 
 import type { PluginInput } from '@opencode-ai/plugin';
+import { isRecord } from '../../utils/guards';
 import { createInternalAgentTextPart } from '../../utils/internal-initiator';
 import { log } from '../../utils/logger';
 import { getClient } from '../../utils/opencode-client';
@@ -278,6 +279,16 @@ const FALLBACK_IN_PROGRESS_KEY = Symbol.for(
   'oh-my-opencode-slim.foreground-fallback.in-progress',
 );
 
+/** Error name stamped by the v2 client shim's promptAsync when the host
+ * provides no session.switchModel while the replay declared
+ * `modelSwitch: 'required'`. Duck-typed by name (mirroring the hostFlavor
+ * convention) so this v1 hook stays decoupled from the v2 adapter module. */
+const V2_SWITCH_MODEL_UNAVAILABLE_ERROR = 'V2SwitchModelUnavailableError';
+
+function isSwitchModelUnavailableError(err: unknown): err is Error {
+  return err instanceof Error && err.name === V2_SWITCH_MODEL_UNAVAILABLE_ERROR;
+}
+
 function getProcessFallbacksInProgress(): Set<string> {
   const globalWithStore = globalThis as typeof globalThis & {
     [FALLBACK_IN_PROGRESS_KEY]?: Set<string>;
@@ -910,12 +921,24 @@ export class ForegroundFallbackManager {
         log('[foreground-fallback] promptAsync unavailable', { sessionID });
         return;
       }
+      // Loose alias: the v2 client shim accepts extra top-level args
+      // (`modelSwitch`) the way orchestrator-wake passes `delivery`.
+      const promptAsync = sessionClient.promptAsync as (
+        args: Record<string, unknown> & { modelSwitch?: 'required' },
+      ) => Promise<unknown>;
 
       const replayParts = partsFromReplayMessage(lastUser) as Array<{
         type: 'text';
         text: string;
       }>;
 
+      // v2-only flag (consumed by the client shim): the replay's model is
+      // the fallback TARGET, so a v2 host without session.switchModel must
+      // reject the replay (typed error) instead of silently replaying on
+      // the model that just failed. v1 call bytes stay untouched.
+      const isV2Host =
+        (this.input as PluginInput & { hostFlavor?: string }).hostFlavor ===
+        'v2';
       const promptBody = {
         path: { id: sessionID },
         body: {
@@ -928,19 +951,40 @@ export class ForegroundFallbackManager {
           model: ref,
           ...(agentName ? { agent: agentName } : {}),
         },
+        ...(isV2Host ? { modelSwitch: 'required' as const } : {}),
       };
 
+      let promptResult: unknown;
       try {
-        await sessionClient.promptAsync(promptBody);
-      } catch (_promptErr) {
+        promptResult = await promptAsync(promptBody);
+      } catch (promptErr) {
+        if (isSwitchModelUnavailableError(promptErr)) {
+          // Not a busy session — the host cannot switch models at all, so
+          // aborting and retrying cannot help (same missing capability on
+          // every attempt). Surface the real cause via the outer handler.
+          throw promptErr;
+        }
         log('[foreground-fallback] promptAsync on busy session, aborting', {
           sessionID,
         });
         await abortSessionWithTimeout(getClient(this.input), sessionID);
         await new Promise((r) => setTimeout(r, REPROMPT_DELAY_MS));
-        await sessionClient.promptAsync(promptBody);
+        promptResult = await promptAsync(promptBody);
       }
 
+      // v2 shim truthfulness: when the replay was delivered on the CURRENT
+      // model (session.switchModel failed mid-replay, `switched: false`),
+      // the switch claim must not be recorded — sessionModel feeds chain
+      // descent and onSessionModelChanged migrates provider accounting;
+      // both would lie. v1 results carry no `switched` key and keep the
+      // claim (v1 parity).
+      if (isRecord(promptResult) && promptResult.switched === false) {
+        log(
+          '[foreground-fallback] fallback prompt delivered on the current model (model switch failed)',
+          { sessionID, agentName, from: currentModel, intended: nextModel },
+        );
+        return;
+      }
       this.sessionModel.set(sessionID, nextModel);
       this.onSessionModelChanged?.(sessionID, nextModel);
       log('[foreground-fallback] switched to fallback model', {

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

@@ -523,6 +523,134 @@ describe('v2 client shim delegation', () => {
   });
 });
 
+describe('v2 client shim promptAsync model-switch hardening (#1125)', () => {
+  function makePromptAsync(overrides?: Partial<V2Context['session']>) {
+    const input = buildPluginInput(makeCtx(overrides));
+    return (
+      input.client as {
+        session: {
+          promptAsync: (
+            a: Record<string, unknown> & {
+              delivery?: 'steer' | 'queue';
+              modelSwitch?: 'required';
+            },
+          ) => Promise<unknown>;
+        };
+      }
+    ).session.promptAsync;
+  }
+
+  test('confirmed switch resolves with switched:true over the ack record', async () => {
+    const seq: Array<{ m: string; i: unknown }> = [];
+    const promptAsync = makePromptAsync({
+      switchModel: async (i: unknown) => {
+        seq.push({ m: 'switchModel', i });
+      },
+      prompt: async (i: unknown) => {
+        seq.push({ m: 'prompt', i });
+        return { data: { id: 'inbox_1' } };
+      },
+    } as never);
+    const res = await promptAsync({
+      path: { id: 'ses_1' },
+      body: {
+        model: { providerID: 'anthropic', modelID: 'claude-x' },
+        parts: [{ type: 'text', text: 'retry me' }],
+      },
+    });
+    expect(seq).toHaveLength(2);
+    // The ack payload passes through untouched; `switched` is additive.
+    expect(res).toEqual({ data: { id: 'inbox_1' }, switched: true });
+  });
+
+  test('switchModel failure degrades: prompt still delivered, switched:false', async () => {
+    const seq: Array<{ m: string; i: unknown }> = [];
+    const promptAsync = makePromptAsync({
+      switchModel: async () => {
+        seq.push({ m: 'switchModel', i: undefined });
+        throw new Error('model not available on host');
+      },
+      prompt: async (i: unknown) => {
+        seq.push({ m: 'prompt', i });
+        return { data: { id: 'inbox_1' } };
+      },
+    } as never);
+    const res = await promptAsync({
+      path: { id: 'ses_1' },
+      body: {
+        model: { providerID: 'anthropic', modelID: 'claude-x' },
+        parts: [{ type: 'text', text: 'retry me' }],
+      },
+      modelSwitch: 'required',
+    });
+    // The prompt delivery is the load-bearing action: the failed switch
+    // must NOT reject the call (that would abort the fallback chain as a
+    // bogus "busy session") — the prompt is steered on the current model.
+    expect(seq.map((e) => e.m)).toEqual(['switchModel', 'prompt']);
+    expect(seq[1]).toMatchObject({
+      m: 'prompt',
+      i: { sessionID: 'ses_1', delivery: 'steer' },
+    });
+    expect(res).toEqual({ data: { id: 'inbox_1' }, switched: false });
+  });
+
+  test('modelSwitch required + host without switchModel → typed throw, no prompt', async () => {
+    const prompts: unknown[] = [];
+    const promptAsync = makePromptAsync({
+      prompt: async (i: unknown) => {
+        prompts.push(i);
+        return {};
+      },
+    } as never);
+    let caught: unknown;
+    try {
+      await promptAsync({
+        path: { id: 'ses_1' },
+        body: {
+          model: { providerID: 'anthropic', modelID: 'claude-x' },
+          parts: [{ type: 'text', text: 'retry me' }],
+        },
+        modelSwitch: 'required',
+      });
+    } catch (err) {
+      caught = err;
+    }
+    // No silent same-model steering: the caller's logs must reflect that
+    // the fallback target model could not be applied.
+    expect(caught).toBeInstanceOf(Error);
+    expect((caught as Error).name).toBe('V2SwitchModelUnavailableError');
+    expect((caught as Error).message).toBe(
+      '[v2] host provides no session.switchModel; cannot switch model for fallback prompt',
+    );
+    expect(prompts).toHaveLength(0);
+  });
+
+  test('modelSwitch absent + model requested keeps the steering degrade (wake pin regression guard)', async () => {
+    // orchestrator-wake passes the session's CURRENT model as a pin; a
+    // host without switchModel must keep steering (logged) — a blanket
+    // throw would suppress every wake on such hosts.
+    const prompts: Array<Record<string, unknown>> = [];
+    const promptAsync = makePromptAsync({
+      prompt: async (i: Record<string, unknown>) => {
+        prompts.push(i);
+        return {};
+      },
+    } as never);
+    const res = await promptAsync({
+      path: { id: 'ses_1' },
+      body: {
+        agent: 'orchestrator',
+        model: { providerID: 'test', modelID: 'model-a' },
+        parts: [{ type: 'text', text: 'wake reminder' }],
+      },
+      delivery: 'queue',
+    });
+    expect(prompts).toHaveLength(1);
+    expect(prompts[0]?.delivery).toBe('queue');
+    expect((res as { switched: boolean }).switched).toBe(false);
+  });
+});
+
 describe('v2 client shim foreground-fallback integration', () => {
   test('replay → switchModel → steer prompt → interrupt flow', async () => {
     const seq: Array<{ m: string; i: unknown }> = [];

+ 45 - 5
src/v2/client-shim.ts

@@ -13,7 +13,12 @@
  * The v2 model-switch semantics (prompts carry no model; `switchModel`
  * must precede the prompt) are encapsulated in the `promptAsync`
  * translation, which is what lets the v1 foreground-fallback pipeline work
- * unmodified on v2.
+ * unmodified on v2. A failed `switchModel` degrades to steering on the
+ * current model (logged, `switched: false` on the result) because the
+ * prompt delivery is the load-bearing action; a host with NO
+ * `switchModel` rejects callers that declare `modelSwitch: 'required'`
+ * (foreground-fallback) while pin-callers (orchestrator-wake) keep the
+ * logged steer.
  */
 
 import { isRecord } from '../utils/guards';
@@ -292,9 +297,18 @@ export function buildPluginInput(
       // v1 prompt_async QUEUED its prompt. The optional `delivery` argument
       // lets callers preserve that on v2 ('queue' — orchestrator-wake);
       // the default stays 'steer' because the foreground-fallback replay
-      // must steer an in-flight run.
+      // must steer an in-flight run. The optional `modelSwitch` argument
+      // declares caller intent for the body model: 'required'
+      // (foreground-fallback — the model is the fallback TARGET, so a host
+      // without session.switchModel must fail loudly instead of silently
+      // replaying on the model that just failed); default callers pass the
+      // session's CURRENT model as a pin (orchestrator-wake) and keep the
+      // honest degrade-with-log steer.
       promptAsync: async (
-        args: Record<string, unknown> & { delivery?: 'steer' | 'queue' },
+        args: Record<string, unknown> & {
+          delivery?: 'steer' | 'queue';
+          modelSwitch?: 'required';
+        },
       ) => {
         if (!s.prompt) {
           throw new Error('[v2] session.prompt unavailable for promptAsync');
@@ -304,9 +318,29 @@ export function buildPluginInput(
           typeof modelRefFromBody
         >[0] & { parts?: Array<{ type?: string; text?: string }> };
         const ref = modelRefFromBody(body);
+        let switched = false;
         if (ref) {
           if (s.switchModel) {
-            await s.switchModel({ sessionID: sessionIDOf(args), model: ref });
+            // 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
+            // aborting the caller's fallback chain (upstream #1125).
+            try {
+              await s.switchModel({ sessionID: sessionIDOf(args), model: ref });
+              switched = true;
+            } catch (err) {
+              log('[v2][shim] session.switchModel failed', {
+                id: sessionIDOf(args),
+                model: ref,
+                error: err instanceof Error ? err.message : String(err),
+              });
+            }
+          } else if (args?.modelSwitch === 'required') {
+            const switchErr = new Error(
+              '[v2] host provides no session.switchModel; cannot switch model for fallback prompt',
+            );
+            switchErr.name = 'V2SwitchModelUnavailableError';
+            throw switchErr;
           } else {
             log(
               '[v2][shim] session.switchModel unavailable; steering on the current model',
@@ -316,13 +350,19 @@ export function buildPluginInput(
         }
         const files = filesFromBody(args);
         const metadata = internalInitiatorMetadataFromBody(args);
-        return s.prompt({
+        const result = await s.prompt({
           sessionID: sessionIDOf(args),
           text: textFromBody(args),
           delivery,
           ...(files.length > 0 ? { files } : {}),
           ...(metadata ? { metadata } : {}),
         });
+        // `switched` reports whether the requested model switch was
+        // CONFIRMED, letting callers gate model-switch bookkeeping on the
+        // truth (foreground-fallback's "switched to fallback model"
+        // claim). Additive over the v2 ack record; callers that ignore
+        // the result are unaffected.
+        return isRecord(result) ? { ...result, switched } : { switched };
       },
       update: s.rename
         ? async (args: Record<string, unknown>) => {

+ 1 - 1
src/v2/codemap.md

@@ -18,7 +18,7 @@ v2 registrations. v1 behavior is unchanged.
 | `setup.ts` | `createV2Setup()` → the `setup(ctx)` orchestrator v2 calls. Capability-guards reduced/TUI-side hosts (no `agent.transform`). Registers agents, tools, MCPs, commands, the merged context hook, tool-execute bridges, and the event pump — each independently try/catch-guarded with a zero-registration health check. Exports the pure command-marker helpers (`wrapCommandMarker`/`parseCommandMarker`/`stripCommandMarker`), `createCommandRegistration`, `applyCommandMarkerToContext`, the merged context-hook builder `createSessionContextHandler`, the tool-execute bridge factory `createToolExecuteBridges`, and `adaptMcpServer`. |
 | `types.ts` | v2 plugin context surface (`V2Context` + draft/event types), mirrored locally (v2 plugin package is not a build-time dependency). Runtime-probed session methods (`get`/`interrupt`/`switchModel`/`context`/`prompt`/`synthetic`/`rename`/`switchAgent`) and the optional `mcp` domain are declared optional with probe notes. |
 | `session-submit.ts` | Shared `createSessionSubmit` (prompt-only user-prompt submit via `ctx.session.prompt`) + `textFromContent`; used by both the generic command bridge and the interview bridge to avoid a setup↔bridge import cycle. |
-| `client-shim.ts` | `buildPluginInput`: constructs a v1-shaped `PluginInput` with a **real-delegation** client — v1 SDK call shapes translate to v2 flat session calls (`get`, `interrupt`, `context`, `prompt` with `delivery:"steer"`, `rename`), with honest degradation (log or omit) where the host lacks the method. `resolveV2Directory` prefers `ctx.location.directory` (#45403+) with a `process.cwd()` fallback. `promptAsync` encapsulates the v2 model-switch semantics (`switchModel` before the prompt) and accepts an optional `delivery` argument (default `"steer"` for the foreground-fallback replay; the orchestrator-wake scheduler passes `"queue"` to match v1's queued prompt_async); internal-initiator body parts (wake prompts) map to prompt `metadata` so the session-prompt bridge can restore the v1 part marker. `session.list` maps v2 `Session.Info` to the v1 `{data}` envelope including `outcome`/`time.updated`/`directory` (interview dashboard scan + orchestrator-wake children enumeration). Marks the input `hostFlavor: 'v2'` (multiplexer gating and wake-mode resolution in `src/index.ts` / `src/hooks/orchestrator-wake/`) and threads the probed `generate.text` channel as `experimental_v2`. Never fakes success shapes (no invented `serverUrl`). |
+| `client-shim.ts` | `buildPluginInput`: constructs a v1-shaped `PluginInput` with a **real-delegation** client — v1 SDK call shapes translate to v2 flat session calls (`get`, `interrupt`, `context`, `prompt` with `delivery:"steer"`, `rename`), with honest degradation (log or omit) where the host lacks the method. `resolveV2Directory` prefers `ctx.location.directory` (#45403+) with a `process.cwd()` fallback. `promptAsync` encapsulates the v2 model-switch semantics (`switchModel` before the prompt) and accepts an optional `delivery` argument (default `"steer"` for the foreground-fallback replay; the orchestrator-wake scheduler passes `"queue"` to match v1's queued prompt_async) plus an optional `modelSwitch: 'required'` argument (foreground-fallback: a host without `session.switchModel` rejects the replay with a typed `V2SwitchModelUnavailableError` instead of silently replaying on the failed model; pin-callers keep the logged steer). A failing `switchModel` degrades to steering on the current model — logged, with `switched: false` attached to the result so foreground-fallback can gate its switch bookkeeping on the truth (#1125); internal-initiator body parts (wake prompts) map to prompt `metadata` so the session-prompt bridge can restore the v1 part marker. `session.list` maps v2 `Session.Info` to the v1 `{data}` envelope including `outcome`/`time.updated`/`directory` (interview dashboard scan + orchestrator-wake children enumeration). Marks the input `hostFlavor: 'v2'` (multiplexer gating and wake-mode resolution in `src/index.ts` / `src/hooks/orchestrator-wake/`) and threads the probed `generate.text` channel as `experimental_v2`. Never fakes success shapes (no invented `serverUrl`). |
 | `delegation.ts` | v2↔v1 delegation tool normalization: `toolNameToV1` (`subagent`→`task`), `subagentArgsToV1` (`agent`→`subagent_type`, `sessionID`→`task_id`), `v1ArgsToSubagent` (reverse). Lets the whole v1 pipeline (task-session-manager, job board, `task_*` tools) run on v2's host `subagent` tool with zero changes. |
 | `event-adapter.ts` | `mapV2EventToV1`: additive-only v2→v1 event synthesis for the event pump. Raw event always first (interview bridge consumes it); payload is read from the live wire key `data` (`{id, created, type, location?, durable?, data}` — verified live on beta-19365) with `properties` as the legacy/test fallback, while every synthesized shape writes `properties` (what the v1 consumers read). Syntheses: idle `session.status` → `session.idle`, `session.execution.*` → v1 busy/idle/error lifecycle shapes, flat child `session.created` → v1 early-registration `{info:{id,parentID,agent?}}`, usage telemetry (`session.usage.updated`/`session.step.ended`) → deduplicated completed-assistant `message.updated` (deterministic fingerprint id; no wall-clock/randomness). |
 | `tui.ts` | v2 TUI plugin entry (`./tui` export → `dist/tui2.js`): re-exports the v1 dual-contract TUI (`../tui`) and extends its v2 `setup` with the `/preset` keymap flow (`ui.dialog.select` + toast; persists via `switchPresetOnDisk`; `/preset <name>` fast path). Capability-guarded: builds without `keymap.layer`/`ui.dialog.select` keep the sidebar and lose only `/preset`. |

+ 64 - 0
src/v2/fallback-attachments.e2e.test.ts

@@ -101,12 +101,18 @@ const ATTACHMENT_TRANSCRIPT = [
 
 async function triggerFailover(
   ctx: V2Context,
+  options?: {
+    onSessionModelChanged?: (sessionID: string, model: string) => void;
+  },
 ): Promise<ForegroundFallbackManager> {
   const input = buildPluginInput(ctx);
   const mgr = new ForegroundFallbackManager(
     { orchestrator: ['anthropic/claude-a', 'anthropic/claude-b'] },
     true,
     input as never,
+    3,
+    undefined,
+    options?.onSessionModelChanged,
   );
   await mgr.handleEvent({
     type: 'message.updated',
@@ -181,3 +187,61 @@ describe('v2 fallback replay attachment preservation (e2e)', () => {
     expect(pi.text).toContain('plain retry');
   });
 });
+
+describe('v2 fallback model-switch hardening (e2e, upstream #1125)', () => {
+  const PLAIN_TRANSCRIPT = [
+    {
+      id: 'm1',
+      role: 'user',
+      content: [{ type: 'text', text: 'analyze the chart' }],
+    },
+  ];
+
+  test('switchModel failure still delivers the replay and skips the switch claim', async () => {
+    // Real manager → real shim: a failing host switchModel must degrade to
+    // a steer prompt on the current model (chain not aborted) while the
+    // manager's bookkeeping records NO switch.
+    const { ctx, seq } = makeCtx({
+      context: async () => PLAIN_TRANSCRIPT,
+      switchModel: async (i: unknown) => {
+        seq.push({ m: 'switchModel-failed', i });
+        throw new Error('model not available on host');
+      },
+    });
+    const onModelChanged = mock();
+    await triggerFailover(ctx, { onSessionModelChanged: onModelChanged });
+
+    const promptCall = seq.find((e) => e.m === 'prompt');
+    if (!promptCall) {
+      throw new Error(
+        'no v2 prompt call captured — degrade dropped the replay',
+      );
+    }
+    const pi = promptCall.i as Record<string, unknown>;
+    expect(pi.delivery).toBe('steer');
+    expect(pi.text).toContain('analyze the chart');
+    // No busy-session abort dance around the degraded delivery.
+    expect(seq.filter((e) => e.m === 'interrupt')).toHaveLength(0);
+    expect(seq.filter((e) => e.m === 'prompt')).toHaveLength(1);
+    // The switch claim (bookkeeping callback) must NOT fire — the session
+    // is still on the model that failed.
+    expect(onModelChanged).not.toHaveBeenCalled();
+  });
+
+  test('host without switchModel rejects the replay without aborting or retrying', async () => {
+    const { ctx, seq } = makeCtx({
+      context: async () => PLAIN_TRANSCRIPT,
+    });
+    // Reduced host: no session.switchModel capability at all.
+    delete (ctx.session as Partial<V2Context['session']>).switchModel;
+    const onModelChanged = mock();
+    await triggerFailover(ctx, { onSessionModelChanged: onModelChanged });
+
+    // No silent same-model steering: the replay is rejected (no prompt
+    // delivered), the error is NOT misclassified as a busy session (no
+    // interrupt/abort, no retry), and no switch is claimed.
+    expect(seq.filter((e) => e.m === 'prompt')).toHaveLength(0);
+    expect(seq.filter((e) => e.m === 'interrupt')).toHaveLength(0);
+    expect(onModelChanged).not.toHaveBeenCalled();
+  });
+});