Browse Source

Merge pull request #965 from adikpb/cleanup/remove-fallback-fields

refactor(config): remove unused FailoverConfigSchema fields + regen schema
Alvin 1 week ago
parent
commit
4439242d26

+ 0 - 19
oh-my-opencode-slim.schema.json

@@ -1091,31 +1091,12 @@
           "default": true,
           "type": "boolean"
         },
-        "timeoutMs": {
-          "default": 15000,
-          "type": "number",
-          "minimum": 0
-        },
-        "retryDelayMs": {
-          "default": 500,
-          "type": "number",
-          "minimum": 0
-        },
         "maxRetries": {
           "default": 3,
           "description": "Number of consecutive 429/rate-limit responses tolerated on the same model before aborting (or swapping to the next fallback model when a chain is configured).",
           "type": "integer",
           "minimum": 0,
           "maximum": 9007199254740991
-        },
-        "retry_on_empty": {
-          "default": true,
-          "description": "When true (default), empty provider responses are treated as failures, triggering fallback/retry. Set to false to treat them as successes.",
-          "type": "boolean"
-        },
-        "runtimeOverride": {
-          "description": "DEPRECATED: no longer used. Previously controlled whether out-of-chain runtime model picks triggered fallback. Fallback is now always disabled when a user explicitly selects a model via /model.",
-          "type": "boolean"
         }
       },
       "additionalProperties": false

+ 37 - 4
src/config/loader.test.ts

@@ -492,7 +492,7 @@ describe('onWarning callback', () => {
     expect(config.agents?.oracle?.model).toBe('valid/model');
   });
 
-  test('deprecated tmux key calls onWarning with invalid-schema and still loads', () => {
+  test('deprecated tmux key calls onWarning with deprecated-key and still loads', () => {
     const projectDir = path.join(tempDir, 'project');
     const projectConfigDir = path.join(projectDir, '.opencode');
     fs.mkdirSync(projectConfigDir, { recursive: true });
@@ -510,12 +510,12 @@ describe('onWarning callback', () => {
     });
 
     expect(warnings).toHaveLength(1);
-    expect(warnings[0]?.kind).toBe('invalid-schema');
+    expect(warnings[0]?.kind).toBe('deprecated-key');
     expect(warnings[0]?.message).toContain('Deprecated tmux config key');
     expect(config.agents?.oracle?.model).toBe('valid/model');
   });
 
-  test('deprecated council.master key calls onWarning with invalid-schema and still loads', () => {
+  test('deprecated council.master key calls onWarning with deprecated-key and still loads', () => {
     const projectDir = path.join(tempDir, 'project');
     const projectConfigDir = path.join(projectDir, '.opencode');
     fs.mkdirSync(projectConfigDir, { recursive: true });
@@ -539,7 +539,7 @@ describe('onWarning callback', () => {
     });
 
     expect(warnings).toHaveLength(1);
-    expect(warnings[0]?.kind).toBe('invalid-schema');
+    expect(warnings[0]?.kind).toBe('deprecated-key');
     expect(warnings[0]?.message).toContain(
       'Deprecated council.master config key',
     );
@@ -834,6 +834,39 @@ describe('deepMerge behavior', () => {
     // Fallback deepMerge: project value wins over user value
     expect(config.fallback?.enabled).toBe(false);
   });
+
+  test('deprecated fallback.* keys warn and still load', () => {
+    const userOpencodeDir = path.join(userConfigDir, 'opencode');
+    fs.mkdirSync(userOpencodeDir, { recursive: true });
+    fs.writeFileSync(
+      path.join(userOpencodeDir, 'oh-my-opencode-slim.json'),
+      JSON.stringify({
+        fallback: {
+          enabled: true,
+          timeoutMs: 15000,
+          retryDelayMs: 500,
+          retry_on_empty: false,
+          runtimeOverride: true,
+        },
+        agents: { oracle: { model: 'valid/model' } },
+      }),
+    );
+
+    const warnings: ConfigLoadWarning[] = [];
+    const config = loadPluginConfig(userConfigDir, {
+      onWarning: (warning) => warnings.push(warning),
+    });
+
+    expect(warnings).toHaveLength(1);
+    expect(warnings[0]?.kind).toBe('deprecated-key');
+    expect(warnings[0]?.message).toContain('Deprecated fallback config keys');
+    expect(warnings[0]?.message).toContain('timeoutMs');
+    expect(config.fallback?.enabled).toBe(true);
+    // Removed fields must not survive into the parsed config
+    expect(config.fallback).not.toHaveProperty('timeoutMs');
+    expect(config.fallback).not.toHaveProperty('runtimeOverride');
+    expect(config.agents?.oracle?.model).toBe('valid/model');
+  });
 });
 
 describe('preset resolution', () => {

+ 30 - 3
src/config/loader.ts

@@ -4,6 +4,7 @@ import { stripJsonComments } from '../cli/config-io';
 import { getConfigSearchDirs } from '../cli/paths';
 import { DEFAULT_DISABLED_AGENTS } from './constants';
 import {
+  LEGACY_FALLBACK_KEYS,
   type PluginConfig,
   PluginConfigSchema,
   WebfetchConfigSchema,
@@ -16,7 +17,8 @@ export type ConfigLoadWarningKind =
   | 'invalid-json'
   | 'invalid-schema'
   | 'read-error'
-  | 'missing-preset';
+  | 'missing-preset'
+  | 'deprecated-key';
 
 /**
  * A warning emitted while loading plugin configuration.
@@ -97,7 +99,7 @@ function loadConfigFromPath(
         'Deprecated tmux config key found and ignored. Use multiplexer config instead.';
       options?.onWarning?.({
         path: configPath,
-        kind: 'invalid-schema' as ConfigLoadWarningKind,
+        kind: 'deprecated-key',
         message: tmuxMsg,
       });
       if (!options?.silent) {
@@ -121,7 +123,7 @@ function loadConfigFromPath(
         'Deprecated council.master config key found and ignored. Configure council agents via presets instead.';
       options?.onWarning?.({
         path: configPath,
-        kind: 'invalid-schema' as ConfigLoadWarningKind,
+        kind: 'deprecated-key',
         message: masterMsg,
       });
       if (!options?.silent) {
@@ -129,6 +131,31 @@ function loadConfigFromPath(
       }
     }
 
+    // Warn about deprecated fallback.* keys. The schema strips these before
+    // validation so the rest of the config still loads; without this warning
+    // users would not know their stale keys are ignored.
+    if (
+      typeof rawConfig === 'object' &&
+      rawConfig !== null &&
+      typeof (rawConfig as Record<string, unknown>).fallback === 'object' &&
+      (rawConfig as Record<string, unknown>).fallback !== null
+    ) {
+      const fallback = (rawConfig as Record<string, unknown>)
+        .fallback as Record<string, unknown>;
+      const present = LEGACY_FALLBACK_KEYS.filter((key) => key in fallback);
+      if (present.length > 0) {
+        const fallbackMsg = `Deprecated fallback config key${present.length === 1 ? '' : 's'} ${present.join(', ')} found and ignored. These fields were removed in 2.3.x; fallback behavior is controlled by fallback.enabled and fallback.maxRetries.`;
+        options?.onWarning?.({
+          path: configPath,
+          kind: 'deprecated-key',
+          message: fallbackMsg,
+        });
+        if (!options?.silent) {
+          console.warn(`[oh-my-opencode-slim] ${fallbackMsg}`);
+        }
+      }
+    }
+
     const result = PluginConfigSchema.safeParse(rawConfig);
 
     if (!result.success) {

+ 50 - 35
src/config/schema.ts

@@ -191,41 +191,56 @@ export const BackgroundJobsConfigSchema = z.object({
 
 export type BackgroundJobsConfig = z.infer<typeof BackgroundJobsConfigSchema>;
 
-export const FailoverConfigSchema = z
-  .object({
-    enabled: z.boolean().default(true),
-    timeoutMs: z.number().min(0).default(15000),
-    retryDelayMs: z.number().min(0).default(500),
-    maxRetries: z
-      .number()
-      .int()
-      .min(0)
-      .default(3)
-      .describe(
-        'Number of consecutive 429/rate-limit responses tolerated on the ' +
-          'same model before aborting (or swapping to the next fallback ' +
-          'model when a chain is configured).',
-      ),
-    retry_on_empty: z
-      .boolean()
-      .default(true)
-      .describe(
-        'When true (default), empty provider responses are treated as failures, ' +
-          'triggering fallback/retry. Set to false to treat them as successes.',
-      ),
-    // DEPRECATED: accepted for backward compatibility but no longer used.
-    // Fallback is now always disabled when a user explicitly selects a model
-    // via /model, so this flag has no effect.
-    runtimeOverride: z
-      .boolean()
-      .optional()
-      .describe(
-        'DEPRECATED: no longer used. Previously controlled whether out-of-chain ' +
-          'runtime model picks triggered fallback. Fallback is now always ' +
-          'disabled when a user explicitly selects a model via /model.',
-      ),
-  })
-  .strict();
+/**
+ * Fallback config fields accepted by versions before 2.3.x but no longer
+ * meaningful. Kept only so that existing user/project configs containing
+ * them still parse: the loader emits a deprecation warning and these keys
+ * are stripped before strict validation. Without this, a stale field would
+ * make the whole config file fail and drop all the user's settings.
+ */
+export const LEGACY_FALLBACK_KEYS = [
+  'timeoutMs',
+  'retryDelayMs',
+  'retry_on_empty',
+  'runtimeOverride',
+] as const;
+
+function stripLegacyFallbackKeys(value: unknown): unknown {
+  if (typeof value !== 'object' || value === null || Array.isArray(value)) {
+    return value;
+  }
+  const record = value as Record<string, unknown>;
+  const hasLegacy = LEGACY_FALLBACK_KEYS.some((key) => key in record);
+  if (!hasLegacy) {
+    return value;
+  }
+  const cleaned: Record<string, unknown> = {};
+  for (const [key, val] of Object.entries(record)) {
+    if (!(LEGACY_FALLBACK_KEYS as readonly string[]).includes(key)) {
+      cleaned[key] = val;
+    }
+  }
+  return cleaned;
+}
+
+export const FailoverConfigSchema = z.preprocess(
+  stripLegacyFallbackKeys,
+  z
+    .object({
+      enabled: z.boolean().default(true),
+      maxRetries: z
+        .number()
+        .int()
+        .min(0)
+        .default(3)
+        .describe(
+          'Number of consecutive 429/rate-limit responses tolerated on the ' +
+            'same model before aborting (or swapping to the next fallback ' +
+            'model when a chain is configured).',
+        ),
+    })
+    .strict(),
+);
 
 export type FailoverConfig = z.infer<typeof FailoverConfigSchema>;
 

+ 123 - 117
src/hooks/cache-safety.property.test.ts

@@ -87,62 +87,63 @@ describe('cache-safety: board strategy coverage drift guard', () => {
   });
 });
 
-describe.each(
-  BOARD_STRATEGIES,
-)('cache-safety: turn-over-turn prefix stability (%s)', (strategy) => {
-  test('re-rendering a growing conversation reproduces byte-identical history', async () => {
-    const pipeline = createPipeline({ strategy });
-    const history = buildHistory();
-    const turns = turnEndIndices(history);
-    const fingerprintsFor = STRATEGY_STABLE_FINGERPRINTS[strategy];
-
-    let previous: string[] | undefined;
-    for (const [turnNumber, endIndex] of turns.entries()) {
-      // Exercise cross-turn hook state: a file-tool nudge fires and a
-      // background job launches before the second turn (a real user turn,
-      // so checkpoint mode creates a snapshot), the job is dropped before
-      // the internal-initiator turn renders with an empty board, and a
-      // second job launches before the fourth turn. Snapshot creation,
-      // replay across internal-initiator and empty-board turns, and
-      // unchanged-board dedupe all must leave stable bytes untouched —
-      // the v2.2.5 checkpoint regression rewrote them on exactly these
-      // transitions.
-      if (turnNumber === 1) {
-        pipeline.markFileToolPending();
-        pipeline.board.registerLaunch({
-          taskID: 'task-alpha',
-          parentSessionID: SESSION_ID,
-          agent: 'explorer',
-          description: 'churn fixture',
-          now: FIXTURE_NOW,
-        });
-      }
-      if (turnNumber === 2) pipeline.board.drop('task-alpha');
-      if (turnNumber === 3) {
-        pipeline.board.registerLaunch({
-          taskID: 'task-beta',
-          parentSessionID: SESSION_ID,
-          agent: 'fixer',
-          description: 'second churn fixture',
-          now: FIXTURE_NOW,
-        });
-      }
+describe.each(BOARD_STRATEGIES)(
+  'cache-safety: turn-over-turn prefix stability (%s)',
+  (strategy) => {
+    test('re-rendering a growing conversation reproduces byte-identical history', async () => {
+      const pipeline = createPipeline({ strategy });
+      const history = buildHistory();
+      const turns = turnEndIndices(history);
+      const fingerprintsFor = STRATEGY_STABLE_FINGERPRINTS[strategy];
+
+      let previous: string[] | undefined;
+      for (const [turnNumber, endIndex] of turns.entries()) {
+        // Exercise cross-turn hook state: a file-tool nudge fires and a
+        // background job launches before the second turn (a real user turn,
+        // so checkpoint mode creates a snapshot), the job is dropped before
+        // the internal-initiator turn renders with an empty board, and a
+        // second job launches before the fourth turn. Snapshot creation,
+        // replay across internal-initiator and empty-board turns, and
+        // unchanged-board dedupe all must leave stable bytes untouched —
+        // the v2.2.5 checkpoint regression rewrote them on exactly these
+        // transitions.
+        if (turnNumber === 1) {
+          pipeline.markFileToolPending();
+          pipeline.board.registerLaunch({
+            taskID: 'task-alpha',
+            parentSessionID: SESSION_ID,
+            agent: 'explorer',
+            description: 'churn fixture',
+            now: FIXTURE_NOW,
+          });
+        }
+        if (turnNumber === 2) pipeline.board.drop('task-alpha');
+        if (turnNumber === 3) {
+          pipeline.board.registerLaunch({
+            taskID: 'task-beta',
+            parentSessionID: SESSION_ID,
+            agent: 'fixer',
+            description: 'second churn fixture',
+            now: FIXTURE_NOW,
+          });
+        }
 
-      const output = await renderTurn(pipeline, history, endIndex);
-      const fingerprints = fingerprintsFor(output.messages);
+        const output = await renderTurn(pipeline, history, endIndex);
+        const fingerprints = fingerprintsFor(output.messages);
 
-      if (previous) {
-        if (fingerprints.length < previous.length) {
-          throw new Error(
-            'A transform removed stable messages between turns — this rewrites the cached prefix. Route the content through src/hooks/cache-safe-injection.ts instead.',
-          );
+        if (previous) {
+          if (fingerprints.length < previous.length) {
+            throw new Error(
+              'A transform removed stable messages between turns — this rewrites the cached prefix. Route the content through src/hooks/cache-safe-injection.ts instead.',
+            );
+          }
+          expect(fingerprints.slice(0, previous.length)).toEqual(previous);
         }
-        expect(fingerprints.slice(0, previous.length)).toEqual(previous);
+        previous = fingerprints;
       }
-      previous = fingerprints;
-    }
-  });
-});
+    });
+  },
+);
 
 describe('cache-safety: turn-over-turn prefix stability', () => {
   test('a consumed file-tool nudge is reproduced by the phase reminder on the next turn', async () => {
@@ -165,43 +166,44 @@ describe('cache-safety: turn-over-turn prefix stability', () => {
   });
 });
 
-describe.each(
-  BOARD_STRATEGIES,
-)('cache-safety: specialist sessions (%s)', (strategy) => {
-  test('non-orchestrator payloads pass through byte-identical', async () => {
-    const pipeline = createPipeline({ strategy });
-    const specialistSession = 'ses_specialist_fixture';
-    const history = [
-      {
-        info: {
-          role: 'user',
-          agent: 'explorer',
-          sessionID: specialistSession,
-          id: 's01',
+describe.each(BOARD_STRATEGIES)(
+  'cache-safety: specialist sessions (%s)',
+  (strategy) => {
+    test('non-orchestrator payloads pass through byte-identical', async () => {
+      const pipeline = createPipeline({ strategy });
+      const specialistSession = 'ses_specialist_fixture';
+      const history = [
+        {
+          info: {
+            role: 'user',
+            agent: 'explorer',
+            sessionID: specialistSession,
+            id: 's01',
+          },
+          parts: [{ type: 'text', text: 'find the config loader' }],
         },
-        parts: [{ type: 'text', text: 'find the config loader' }],
-      },
-      assistantTurn('s02', 'Searching now.'),
-      {
-        info: {
-          role: 'user',
-          agent: 'explorer',
-          sessionID: specialistSession,
-          id: 's03',
+        assistantTurn('s02', 'Searching now.'),
+        {
+          info: {
+            role: 'user',
+            agent: 'explorer',
+            sessionID: specialistSession,
+            id: 's03',
+          },
+          parts: [{ type: 'text', text: 'summarize what you found' }],
         },
-        parts: [{ type: 'text', text: 'summarize what you found' }],
-      },
-    ];
-    const before = history.map((message) => JSON.stringify(message));
+      ];
+      const before = history.map((message) => JSON.stringify(message));
 
-    const output: TransformOutput = { messages: structuredClone(history) };
-    await pipeline.run(output);
+      const output: TransformOutput = { messages: structuredClone(history) };
+      await pipeline.run(output);
 
-    expect(output.messages.map((message) => JSON.stringify(message))).toEqual(
-      before,
-    );
-  });
-});
+      expect(output.messages.map((message) => JSON.stringify(message))).toEqual(
+        before,
+      );
+    });
+  },
+);
 
 describe('cache-safety: volatile content isolation', () => {
   test('background-job state only ever changes the tagged trailing message', async () => {
@@ -292,40 +294,44 @@ describe('cache-safety: volatile content isolation', () => {
   });
 });
 
-describe.each(
-  BOARD_STRATEGIES,
-)('cache-safety: determinism under ambient inputs (%s)', (strategy) => {
-  test('wall clock and randomness never leak into the payload', async () => {
-    const history = buildHistory();
-    const lastTurn = history.length - 1;
-    const originalRandom = Math.random;
-
-    const render = async (time: number, random: number): Promise<string[]> => {
-      setSystemTime(new Date(time));
-      Math.random = () => random;
-      try {
-        const pipeline = createPipeline({ strategy });
-        pipeline.board.registerLaunch({
-          taskID: 'task-gamma',
-          parentSessionID: SESSION_ID,
-          agent: 'oracle',
-          description: 'determinism fixture',
-          now: FIXTURE_NOW,
-        });
-        const output = await renderTurn(pipeline, history, lastTurn);
-        return output.messages.map((message) => JSON.stringify(message));
-      } finally {
-        Math.random = originalRandom;
-        setSystemTime();
-      }
-    };
+describe.each(BOARD_STRATEGIES)(
+  'cache-safety: determinism under ambient inputs (%s)',
+  (strategy) => {
+    test('wall clock and randomness never leak into the payload', async () => {
+      const history = buildHistory();
+      const lastTurn = history.length - 1;
+      const originalRandom = Math.random;
+
+      const render = async (
+        time: number,
+        random: number,
+      ): Promise<string[]> => {
+        setSystemTime(new Date(time));
+        Math.random = () => random;
+        try {
+          const pipeline = createPipeline({ strategy });
+          pipeline.board.registerLaunch({
+            taskID: 'task-gamma',
+            parentSessionID: SESSION_ID,
+            agent: 'oracle',
+            description: 'determinism fixture',
+            now: FIXTURE_NOW,
+          });
+          const output = await renderTurn(pipeline, history, lastTurn);
+          return output.messages.map((message) => JSON.stringify(message));
+        } finally {
+          Math.random = originalRandom;
+          setSystemTime();
+        }
+      };
 
-    const first = await render(FIXTURE_NOW, 0.1234);
-    const second = await render(FIXTURE_NOW + 987_654_321, 0.9876);
+      const first = await render(FIXTURE_NOW, 0.1234);
+      const second = await render(FIXTURE_NOW + 987_654_321, 0.9876);
 
-    expect(second).toEqual(first);
-  });
-});
+      expect(second).toEqual(first);
+    });
+  },
+);
 
 describe('cache-safety: pipeline drift guard', () => {
   const srcRoot = path.resolve(import.meta.dir, '..');

+ 66 - 63
src/hooks/task-session-manager/index.test.ts

@@ -4622,77 +4622,80 @@ describe('task-session-manager hook', () => {
   test.each([
     ['foreground-created-first', ['foreground-child', 'background-child']],
     ['background-created-first', ['background-child', 'foreground-child']],
-  ])('ambiguous early created events never supervise the foreground child (%s)', async (_, createdOrder) => {
-    const board = new BackgroundJobBoard();
-    const clock = createSupervisorClock();
-    const abort = mock(async () => undefined);
-    const supervisor = new BackgroundJobSupervisor({
-      backgroundJobStore: board,
-      wallClockTimeoutMs: 100,
-      abortGraceMs: 10,
-      abort,
-      now: clock.now,
-      setTimeout: clock.setTimeout,
-      clearTimeout: clock.clearTimeout,
-    });
-    const { hook } = createHook({
-      backgroundJobBoard: board,
-      backgroundJobSupervisor: supervisor,
-    });
+  ])(
+    'ambiguous early created events never supervise the foreground child (%s)',
+    async (_, createdOrder) => {
+      const board = new BackgroundJobBoard();
+      const clock = createSupervisorClock();
+      const abort = mock(async () => undefined);
+      const supervisor = new BackgroundJobSupervisor({
+        backgroundJobStore: board,
+        wallClockTimeoutMs: 100,
+        abortGraceMs: 10,
+        abort,
+        now: clock.now,
+        setTimeout: clock.setTimeout,
+        clearTimeout: clock.clearTimeout,
+      });
+      const { hook } = createHook({
+        backgroundJobBoard: board,
+        backgroundJobSupervisor: supervisor,
+      });
 
-    await hook['tool.execute.before'](
-      { tool: 'task', sessionID: 'parent-1', callID: 'background-call' },
-      {
-        args: {
-          subagent_type: 'explorer',
-          background: true,
-          description: 'background child',
+      await hook['tool.execute.before'](
+        { tool: 'task', sessionID: 'parent-1', callID: 'background-call' },
+        {
+          args: {
+            subagent_type: 'explorer',
+            background: true,
+            description: 'background child',
+          },
         },
-      },
-    );
-    await hook['tool.execute.before'](
-      { tool: 'task', sessionID: 'parent-1', callID: 'foreground-call' },
-      {
-        args: {
-          subagent_type: 'explorer',
-          background: false,
-          description: 'foreground child',
+      );
+      await hook['tool.execute.before'](
+        { tool: 'task', sessionID: 'parent-1', callID: 'foreground-call' },
+        {
+          args: {
+            subagent_type: 'explorer',
+            background: false,
+            description: 'foreground child',
+          },
         },
-      },
-    );
+      );
 
-    for (const taskID of createdOrder) {
-      await hook.event({
-        event: {
-          type: 'session.created',
-          properties: { info: { id: taskID, parentID: 'parent-1' } },
-        },
-      });
-    }
+      for (const taskID of createdOrder) {
+        await hook.event({
+          event: {
+            type: 'session.created',
+            properties: { info: { id: taskID, parentID: 'parent-1' } },
+          },
+        });
+      }
 
-    expect(board.get('background-child')?.background).toBe(false);
-    expect(board.get('foreground-child')?.background).toBe(false);
-    expect(abort).not.toHaveBeenCalled();
+      expect(board.get('background-child')?.background).toBe(false);
+      expect(board.get('foreground-child')?.background).toBe(false);
+      expect(abort).not.toHaveBeenCalled();
 
-    await hook['tool.execute.after'](
-      { tool: 'task', sessionID: 'parent-1', callID: 'foreground-call' },
-      { output: taskLaunchOutput('foreground-child') },
-    );
-    await hook['tool.execute.after'](
-      { tool: 'task', sessionID: 'parent-1', callID: 'background-call' },
-      { output: taskLaunchOutput('background-child') },
-    );
+      await hook['tool.execute.after'](
+        { tool: 'task', sessionID: 'parent-1', callID: 'foreground-call' },
+        { output: taskLaunchOutput('foreground-child') },
+      );
+      await hook['tool.execute.after'](
+        { tool: 'task', sessionID: 'parent-1', callID: 'background-call' },
+        { output: taskLaunchOutput('background-child') },
+      );
 
-    expect(board.get('foreground-child')?.background).toBe(false);
-    expect(board.get('background-child')?.background).toBe(true);
-    const backgroundJob = board.get('background-child');
-    expect(backgroundJob).toBeDefined();
-    const deadline = (backgroundJob?.runStartedAt ?? 0) + 100;
-    await clock.advanceTo(deadline);
+      expect(board.get('foreground-child')?.background).toBe(false);
+      expect(board.get('background-child')?.background).toBe(true);
+      const backgroundJob = board.get('background-child');
+      expect(backgroundJob).toBeDefined();
+      const deadline = (backgroundJob?.runStartedAt ?? 0) + 100;
+      await clock.advanceTo(deadline);
 
-    expect(abort).toHaveBeenCalledTimes(1);
-    expect(abort).toHaveBeenCalledWith('background-child');
-  });
+      expect(abort).toHaveBeenCalledTimes(1);
+      expect(abort).toHaveBeenCalledWith('background-child');
+    },
+  );
 
   test('missing after-hook callID fails closed while an exact background call remains', async () => {
     const board = new BackgroundJobBoard();

+ 26 - 0
src/tui.test.ts

@@ -123,6 +123,32 @@ describe('readConfigInvalid', () => {
     }
   });
 
+  test('returns false for config with deprecated fallback keys (loads fine)', () => {
+    const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'omos-tui-'));
+    try {
+      const projectDir = path.join(tempDir, 'project');
+      const configDir = path.join(projectDir, '.opencode');
+      fs.mkdirSync(configDir, { recursive: true });
+      fs.writeFileSync(
+        path.join(configDir, 'oh-my-opencode-slim.json'),
+        JSON.stringify({
+          fallback: {
+            enabled: true,
+            timeoutMs: 15000,
+            runtimeOverride: true,
+          },
+          agents: { oracle: { model: 'valid/model' } },
+        }),
+      );
+
+      // Deprecated fallback keys are stripped with a warning; the config
+      // loads successfully so the sidebar must NOT show "Config invalid".
+      expect(readConfigInvalid(projectDir)).toBe(false);
+    } finally {
+      fs.rmSync(tempDir, { recursive: true, force: true });
+    }
+  });
+
   test('uses compact sidebar by default', () => {
     const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'omos-tui-'));
     try {

+ 12 - 2
src/tui.ts

@@ -297,8 +297,18 @@ function readConfigState(directory: string): {
   let configInvalid = false;
   const config = loadPluginConfig(directory, {
     silent: true,
-    onWarning: () => {
-      configInvalid = true;
+    onWarning: (warning) => {
+      // Only genuinely broken configs (parse/load/schema failures) mark the
+      // sidebar invalid. Benign deprecation notices (deprecated-key) and
+      // missing-preset do not, otherwise a config that loads fine would be
+      // shown as "Config invalid".
+      if (
+        warning.kind === 'invalid-json' ||
+        warning.kind === 'invalid-schema' ||
+        warning.kind === 'read-error'
+      ) {
+        configInvalid = true;
+      }
     },
   });
   const compactSidebar = config.compactSidebar ?? true;

+ 24 - 21
src/utils/background-job-supervisor.test.ts

@@ -134,29 +134,32 @@ describe('BackgroundJobSupervisor', () => {
     ['resolve', async () => undefined],
     ['reject', async () => Promise.reject(new Error('abort failed'))],
     ['hang', () => new Promise<never>(() => {})],
-  ])('abort %s is requested once and grace remains independent', async (_, abortCall) => {
-    const { board, supervisor, timers, abort } = createSupervisor({
-      abort: abortCall,
-    });
-    const job = launch(board, true);
-    supervisor.onLaunch(job);
-    await timers.advanceTo(100);
-    await timers.advanceTo(119);
+  ])(
+    'abort %s is requested once and grace remains independent',
+    async (_, abortCall) => {
+      const { board, supervisor, timers, abort } = createSupervisor({
+        abort: abortCall,
+      });
+      const job = launch(board, true);
+      supervisor.onLaunch(job);
+      await timers.advanceTo(100);
+      await timers.advanceTo(119);
 
-    expect(abort).toHaveBeenCalledTimes(1);
-    expect(board.get(job.taskID)?.state).toBe('running');
-    await timers.advanceTo(120);
+      expect(abort).toHaveBeenCalledTimes(1);
+      expect(board.get(job.taskID)?.state).toBe('running');
+      await timers.advanceTo(120);
 
-    expect(board.get(job.taskID)).toMatchObject({
-      state: 'error',
-      timedOut: true,
-      statusUncertain: true,
-      cancellationRequested: true,
-    });
-    expect(board.getResultSummary(job.taskID)).toContain(
-      'abort was not confirmed',
-    );
-  });
+      expect(board.get(job.taskID)).toMatchObject({
+        state: 'error',
+        timedOut: true,
+        statusUncertain: true,
+        cancellationRequested: true,
+      });
+      expect(board.getResultSummary(job.taskID)).toContain(
+        'abort was not confirmed',
+      );
+    },
+  );
 
   test('completion after the deadline claim cannot replace the timeout', async () => {
     const { board, coordinator, supervisor, timers, abort } =

+ 6 - 11
src/utils/env.test.ts

@@ -10,17 +10,12 @@ describe('isTruthyEnvValue', () => {
     expect(isTruthyEnvValue(value)).toBe(true);
   });
 
-  test.each([
-    undefined,
-    '',
-    '0',
-    'false',
-    'no',
-    'off',
-    'anything',
-  ])('%p is not truthy', (value) => {
-    expect(isTruthyEnvValue(value)).toBe(false);
-  });
+  test.each([undefined, '', '0', 'false', 'no', 'off', 'anything'])(
+    '%p is not truthy',
+    (value) => {
+      expect(isTruthyEnvValue(value)).toBe(false);
+    },
+  );
 });
 
 describe('isPluginDisabledByEnv', () => {