Browse Source

fix(board): checkpoint-compatible board injection breaking provider cache prefix

Daniel Maly 2 weeks ago
parent
commit
d406ed3ff9

+ 10 - 7
docs/background-orchestration.md

@@ -390,13 +390,16 @@ For checkpoint-oriented workflows, opt in to the append-only strategy:
 }
 ```
 
-`checkpoint-compatible` preserves prior board snapshots and appends a trailing
-snapshot only when the formatted board changes. Re-running injection with an
-unchanged board does not create a duplicate. This changes board message history
-only; task coordination, storage, terminal reconciliation, and reusable-session
-behavior remain unchanged. The retained snapshot cache is in memory, is limited
-to 20 snapshots per cache epoch, and is reset when OpenCode reports a session
-boundary or a compacted/rebased message history. `maxRetainedSnapshots` controls
+`checkpoint-compatible` preserves prior board snapshots and appends a snapshot
+anchored after the current real-message tail only when the formatted board
+changes. Once created, snapshots are replayed on every managed turn, including
+turns with an internal initiator or an empty board; those turns do not create a
+new snapshot. Re-running injection with an unchanged board does not create a
+duplicate. This changes board message history only; task coordination, storage,
+terminal reconciliation, and reusable-session behavior remain unchanged. The
+retained snapshot cache is in memory, is limited to 20 snapshots per cache epoch,
+and is reset when OpenCode reports a session boundary or a compacted/rebased
+message history. `maxRetainedSnapshots` controls
 the epoch size and accepts integers from 1 to 100 (default `20`). When a changed
 snapshot would exceed the configured limit, all retained snapshots are discarded
 and only the new current snapshot is kept. This intentionally creates one cache

+ 511 - 0
src/hooks/task-session-manager/board-injection-cache-safety.test.ts

@@ -0,0 +1,511 @@
+/**
+ * Regression coverage for checkpoint-compatible board injection and provider
+ * prompt-cache prefix safety.
+ *
+ * Empirical verification of the checkpoint-compatible board strategy's
+ * cache behavior (fix/checkpoint-cache-epochs investigation). Simulates a
+ * multi-turn orchestrator tool loop with realistic SDK-shaped message
+ * fixtures (github-copilot / gpt-5.6-luna) and measures, per turn:
+ *
+ *  1. transform-level bytes: the exact `messages` array the hook produces
+ *  2. provider-level bytes: those messages passed through the same
+ *     UIMessage construction as opencode's MessageV2.toModelMessagesEffect
+ *     (text path, verbatim from
+ *     .slim/clonedeps/repos/opencode/packages/opencode/src/session/message-v2.ts:692-780)
+ *     and then through a local provider-message serializer that keeps only
+ *     provider-visible role and ordered text content.
+ *
+ * For each consecutive pair of requests it computes the longest common
+ * byte prefix and reports where the divergence falls.
+ *
+ * Scenarios A and C intentionally fail against the current implementation.
+ * They become passing guards when board snapshots are kept in the trailing
+ * cache-safe zone and retained snapshots are replayed on every qualifying
+ * request.
+ */
+import { describe, expect, mock, test } from 'bun:test';
+import { DEFAULT_MAX_RETAINED_SNAPSHOTS } from '../../config/constants';
+import { BackgroundJobBoard, createInternalAgentTextPart } from '../../utils';
+import { createTaskSessionManagerHook } from './index';
+
+const SESSION = 'ses_08f6be16dffednbwYD8dNDIfOI';
+const BASE_TIME = 1752968000000;
+
+// ── Realistic SDK-shaped fixtures (types.gen.d.ts UserMessage/AssistantMessage) ──
+
+function userMsg(id: string, text: string, createdAt: number) {
+  return {
+    info: {
+      id,
+      sessionID: SESSION,
+      role: 'user',
+      time: { created: createdAt },
+      agent: 'orchestrator',
+      model: { providerID: 'github-copilot', modelID: 'gpt-5.6-luna' },
+    },
+    parts: [
+      {
+        id: `prt_${id}`,
+        sessionID: SESSION,
+        messageID: id,
+        type: 'text',
+        text,
+        time: { start: createdAt },
+      },
+    ],
+  };
+}
+
+function internalInitiatorUserMsg(id: string, text: string, createdAt: number) {
+  const msg = userMsg(id, text, createdAt);
+  msg.parts = [
+    {
+      id: `prt_${id}`,
+      sessionID: SESSION,
+      messageID: id,
+      ...createInternalAgentTextPart(text),
+    } as never,
+  ];
+  return msg;
+}
+
+function assistantMsg(
+  id: string,
+  text: string,
+  createdAt: number,
+  cost: number,
+  inputTokens: number,
+) {
+  return {
+    info: {
+      id,
+      sessionID: SESSION,
+      role: 'assistant',
+      time: { created: createdAt, completed: createdAt + 4321 },
+      parentID: 'msg_u1',
+      modelID: 'gpt-5.6-luna',
+      providerID: 'github-copilot',
+      mode: 'orchestrator',
+      path: { cwd: '/work/repo', root: '/work/repo' },
+      cost,
+      tokens: {
+        input: inputTokens,
+        output: 350,
+        reasoning: 0,
+        cache: { read: 0, write: 0 },
+      },
+    },
+    parts: [
+      {
+        id: `prt_${id}`,
+        sessionID: SESSION,
+        messageID: id,
+        type: 'text',
+        text,
+      },
+    ],
+  };
+}
+
+// ── Serialization / diffing ────────────────────────────────────────────
+
+function sortValue(value: unknown): unknown {
+  if (Array.isArray(value)) return value.map(sortValue);
+  if (value && typeof value === 'object') {
+    return Object.fromEntries(
+      Object.entries(value as Record<string, unknown>)
+        .filter(([, v]) => typeof v !== 'function' && v !== undefined)
+        .sort(([a], [b]) => (a < b ? -1 : 1))
+        .map(([k, v]) => [k, sortValue(v)]),
+    );
+  }
+  return value;
+}
+
+/** One JSON line per message so "prev is a byte prefix of next" is meaningful. */
+function serializeMessages(messages: unknown[]): string {
+  return `${messages.map((m) => JSON.stringify(sortValue(m))).join('\n')}\n`;
+}
+
+/**
+ * Verbatim port of the text-message path of MessageV2.toModelMessagesEffect
+ * (message-v2.ts:692-780, same-model branch). This is the code that turns the
+ * transform hook's output into UI messages before provider serialization.
+ */
+function toUIMessages(messages: any[]): any[] {
+  const result: any[] = [];
+  for (const msg of messages) {
+    if (!msg?.info || !Array.isArray(msg.parts) || msg.parts.length === 0) {
+      continue;
+    }
+    if (msg.info.role === 'user') {
+      const parts: any[] = [];
+      for (const part of msg.parts) {
+        if (part.type === 'text' && !part.ignored && part.text !== '') {
+          parts.push({ type: 'text', text: part.text });
+        }
+      }
+      if (parts.length > 0) {
+        result.push({ id: msg.info.id, role: 'user', parts });
+      }
+    }
+    if (msg.info.role === 'assistant') {
+      if (msg.info.error) continue;
+      const parts: any[] = [];
+      for (const part of msg.parts) {
+        if (part.type === 'text') {
+          parts.push({
+            type: 'text',
+            text: part.text,
+            providerMetadata: part.metadata,
+          });
+        }
+      }
+      if (parts.length > 0) {
+        result.push({ id: msg.info.id, role: 'assistant', parts });
+      }
+    }
+  }
+  return result;
+}
+
+function toProviderBytes(messages: unknown[]): string {
+  const modelMessages = toUIMessages(messages as never).map(
+    ({ role, parts }: any) => ({
+      role,
+      content: parts.map(({ type, text }: any) => ({ type, text })),
+    }),
+  );
+  return serializeMessages(modelMessages);
+}
+
+function commonPrefixLength(a: string, b: string): number {
+  const n = Math.min(a.length, b.length);
+  let i = 0;
+  while (i < n && a[i] === b[i]) i += 1;
+  return i;
+}
+
+function describeTransition(label: string, prev: string, next: string) {
+  const lcp = commonPrefixLength(prev, next);
+  const safe = lcp === prev.length && next.length > prev.length;
+  const divergingLine = prev.slice(0, lcp).split('\n').length - 1;
+  return {
+    label,
+    safe,
+    lcp,
+    prevLength: prev.length,
+    divergingMessageIndex: safe ? undefined : divergingLine,
+    prevAtDivergence: safe
+      ? undefined
+      : prev.slice(Math.max(0, lcp - 60), lcp + 160),
+    nextAtDivergence: safe
+      ? undefined
+      : next.slice(Math.max(0, lcp - 60), lcp + 160),
+  };
+}
+
+// ── Hook + turn runner ────────────────────────────────────────────────
+
+function createHook(board: BackgroundJobBoard) {
+  return createTaskSessionManagerHook(
+    {
+      client: { session: { status: mock(async () => ({ data: {} })) } },
+      directory: '/tmp',
+      worktree: '/tmp',
+    } as never,
+    {
+      maxSessionsPerAgent: 4,
+      maxRetainedSnapshots: DEFAULT_MAX_RETAINED_SNAPSHOTS,
+      strategy: 'checkpoint-compatible',
+      backgroundJobBoard: board,
+      shouldManageSession: () => true,
+    },
+  );
+}
+
+type TurnResult = {
+  injected: unknown[];
+  transformBytes: string;
+  providerBytes: string;
+};
+
+async function runTurn(
+  hook: ReturnType<typeof createTaskSessionManagerHook>,
+  history: unknown[],
+): Promise<TurnResult> {
+  // prompt.ts rebuilds msgs from storage every request; injected synthetic
+  // board messages are never persisted, so each request starts from the
+  // real history only.
+  const request = { messages: structuredClone(history) };
+  await hook.injectBackgroundJobBoard({}, request as never);
+  return {
+    injected: request.messages,
+    transformBytes: serializeMessages(request.messages),
+    providerBytes: toProviderBytes(request.messages),
+  };
+}
+
+function summarizeInjected(messages: any[]): string[] {
+  return messages.map((m) => {
+    const meta = m.parts?.[0]?.metadata;
+    if (meta?.['oh-my-opencode-slim.backgroundJobBoard'] === true) {
+      return `BOARD(${meta.snapshotID ?? m.info.id})`;
+    }
+    return `${m.info.role.toUpperCase()}(${m.info.id})`;
+  });
+}
+
+// ── Scenario A: tool loop, board state changes across turns ──────────
+
+describe('checkpoint-compatible board cache safety', () => {
+  test('keeps provider bytes prefix-safe while a tool loop changes the board', async () => {
+    const board = new BackgroundJobBoard();
+    const hook = createHook(board);
+
+    const u1 = userMsg('msg_u1', 'Coordinate the refactor work', BASE_TIME);
+    const history: unknown[] = [u1];
+
+    const boardMutations: Array<() => void> = [
+      () =>
+        board.registerLaunch({
+          taskID: 'child-1',
+          parentSessionID: SESSION,
+          agent: 'explorer',
+          description: 'map scheduler hooks',
+        }),
+      () =>
+        board.registerLaunch({
+          taskID: 'child-2',
+          parentSessionID: SESSION,
+          agent: 'oracle',
+          description: 'review plan',
+        }),
+      () =>
+        void board.updateStatus({
+          taskID: 'child-1',
+          state: 'completed',
+          resultSummary: 'mapped hooks',
+        }),
+      () =>
+        board.registerLaunch({
+          taskID: 'child-3',
+          parentSessionID: SESSION,
+          agent: 'librarian',
+          description: 'find docs',
+        }),
+      () =>
+        void board.updateStatus({
+          taskID: 'child-2',
+          state: 'completed',
+          resultSummary: 'plan ok',
+        }),
+    ];
+
+    const turns: TurnResult[] = [];
+    for (let turn = 0; turn < boardMutations.length; turn += 1) {
+      boardMutations[turn]();
+      if (turn > 0) {
+        history.push(
+          assistantMsg(
+            `msg_a${turn}`,
+            `tool step ${turn} output`,
+            BASE_TIME + turn * 30_000,
+            0.0123 * turn,
+            5000 + turn * 800,
+          ),
+        );
+      }
+      turns.push(await runTurn(hook, history));
+    }
+
+    console.log('\n=== SCENARIO A: injected message layout per turn ===');
+    turns.forEach((t, i) => {
+      console.log(
+        `turn ${i + 1}: [${summarizeInjected(t.injected as never).join(', ')}]`,
+      );
+    });
+
+    console.log('\n=== SCENARIO A: provider-byte transitions ===');
+    const transitions = [];
+    for (let i = 1; i < turns.length; i += 1) {
+      const t = describeTransition(
+        `turn ${i} -> turn ${i + 1}`,
+        turns[i - 1].providerBytes,
+        turns[i].providerBytes,
+      );
+      transitions.push(t);
+      console.log(JSON.stringify(t, null, 2));
+    }
+
+    const broken = transitions.filter((t) => !t.safe);
+    console.log(
+      `\nSCENARIO A RESULT: ${broken.length}/${transitions.length} transitions break the provider byte prefix`,
+    );
+
+    // Every request must preserve the complete prior provider payload as a
+    // byte prefix. The current placement bug makes the later transitions
+    // fail here when a snapshot is inserted before prior assistant messages.
+    for (const transition of transitions) {
+      expect(transition.safe, `${transition.label} rewrote cached bytes`).toBe(
+        true,
+      );
+    }
+  }, 20_000);
+
+  // ── Scenario B: board stable, new real user turn (metadata stamping) ──
+
+  test('keeps metadata stamping isolated from provider bytes', async () => {
+    const board = new BackgroundJobBoard();
+    board.registerLaunch({
+      taskID: 'child-1',
+      parentSessionID: SESSION,
+      agent: 'explorer',
+      description: 'map scheduler hooks',
+    });
+    const hook = createHook(board);
+
+    const u1 = userMsg('msg_u1', 'Coordinate the refactor work', BASE_TIME);
+    const turn1 = await runTurn(hook, [u1]);
+
+    // New real user turn: same board state, different triggering message
+    // with different volatile info (time.created).
+    const a1 = assistantMsg(
+      'msg_a1',
+      'done thinking',
+      BASE_TIME + 30_000,
+      0.02,
+      6000,
+    );
+    const u2 = userMsg('msg_u2', 'Now do the next piece', BASE_TIME + 120_000);
+    const turn2 = await runTurn(hook, [u1, a1, u2]);
+
+    const boardMessageOf = (t: TurnResult): any =>
+      (t.injected as never[]).find(
+        (m: any) =>
+          m.parts?.[0]?.metadata?.['oh-my-opencode-slim.backgroundJobBoard'] ===
+          true,
+      );
+
+    const s1turn1 = JSON.stringify(sortValue(boardMessageOf(turn1)));
+    const s1turn2 = JSON.stringify(sortValue(boardMessageOf(turn2)));
+    const lcp = commonPrefixLength(s1turn1, s1turn2);
+
+    console.log('\n=== SCENARIO B: same snapshot S1 across turns ===');
+    console.log('turn1 S1 message:', s1turn1.slice(0, 400));
+    console.log('turn2 S1 message:', s1turn2.slice(0, 400));
+    if (s1turn1 !== s1turn2) {
+      console.log(
+        `transform-level divergence at byte ${lcp}:\n  turn1: ...${s1turn1.slice(Math.max(0, lcp - 40), lcp + 60)}\n  turn2: ...${s1turn2.slice(Math.max(0, lcp - 40), lcp + 60)}`,
+      );
+    }
+
+    const transition = describeTransition(
+      'turn1 -> turn2 (provider bytes)',
+      turn1.providerBytes,
+      turn2.providerBytes,
+    );
+    console.log('provider transition:', JSON.stringify(transition, null, 2));
+    console.log(
+      'provider bytes mention time.created value?',
+      turn2.providerBytes.includes(String(BASE_TIME + 120_000)),
+    );
+    console.log(
+      'provider bytes mention snapshotID/metadata/cost?',
+      turn2.providerBytes.includes('snapshotID'),
+      turn2.providerBytes.includes('backgroundJobBoard'),
+      turn2.providerBytes.includes('"cost"'),
+    );
+
+    // Transform-level: replayed historical snapshot bytes DO change
+    // (baseMessage.info spread stamps the new turn's volatile info).
+    expect(s1turn1).not.toBe(s1turn2);
+    // Provider-level: none of that reaches the request body; transition is
+    // a pure prefix extension.
+    expect(transition.safe).toBe(true);
+    expect(turn2.providerBytes).not.toContain('snapshotID');
+    expect(turn2.providerBytes).not.toContain(String(BASE_TIME + 120_000));
+  }, 20_000);
+
+  // ── Scenario C: retained snapshots vanish and reappear ────────────────
+
+  test('keeps retained snapshots across internal and empty-board turns', async () => {
+    const board = new BackgroundJobBoard();
+    board.registerLaunch({
+      taskID: 'child-1',
+      parentSessionID: SESSION,
+      agent: 'explorer',
+      description: 'map scheduler hooks',
+    });
+    const hook = createHook(board);
+
+    const u1 = userMsg('msg_u1', 'Coordinate the refactor work', BASE_TIME);
+    const history: unknown[] = [u1];
+    const turn1 = await runTurn(hook, history);
+
+    // Turn 2: internal continuation prompt (background completion nudge).
+    history.push(
+      assistantMsg('msg_a1', 'step output', BASE_TIME + 30_000, 0.01, 5000),
+    );
+    history.push(
+      internalInitiatorUserMsg(
+        'msg_u2',
+        'Background task completed, continue.',
+        BASE_TIME + 60_000,
+      ),
+    );
+    const turn2 = await runTurn(hook, history);
+
+    // Turn 3: board becomes empty -> formatForPrompt returns undefined.
+    board.drop('child-1');
+    history.push(
+      userMsg('msg_u3', 'Carry on with the plan', BASE_TIME + 90_000),
+    );
+    const turn3 = await runTurn(hook, history);
+
+    // Turn 4: a new job appears -> retained snapshots come back.
+    board.registerLaunch({
+      taskID: 'child-2',
+      parentSessionID: SESSION,
+      agent: 'oracle',
+      description: 'review plan',
+    });
+    history.push(
+      assistantMsg('msg_a2', 'more output', BASE_TIME + 100_000, 0.02, 7000),
+    );
+    const turn4 = await runTurn(hook, history);
+
+    console.log('\n=== SCENARIO C: injected layout per turn ===');
+    for (const [i, t] of [turn1, turn2, turn3, turn4].entries()) {
+      console.log(
+        `turn ${i + 1}: [${summarizeInjected(t.injected as never).join(', ')}]`,
+      );
+    }
+    const transitions = [
+      describeTransition(
+        'turn1 -> turn2',
+        turn1.providerBytes,
+        turn2.providerBytes,
+      ),
+      describeTransition(
+        'turn2 -> turn3',
+        turn2.providerBytes,
+        turn3.providerBytes,
+      ),
+      describeTransition(
+        'turn3 -> turn4',
+        turn3.providerBytes,
+        turn4.providerBytes,
+      ),
+    ];
+    for (const t of transitions) console.log(JSON.stringify(t, null, 2));
+
+    // The current implementation drops this snapshot on the internal turn;
+    // the prefix assertion below is the regression guard for that transition.
+    for (const transition of transitions) {
+      expect(transition.safe, `${transition.label} rewrote cached bytes`).toBe(
+        true,
+      );
+    }
+  }, 20_000);
+});

+ 52 - 46
src/hooks/task-session-manager/board-injection.ts

@@ -269,6 +269,11 @@ export async function injectBackgroundJobBoard(
     stripTaggedContent(messages, state.metadataKey);
   }
 
+  if (state.strategy === 'checkpoint-compatible') {
+    injectCheckpointBoard(state, messages);
+    return;
+  }
+
   for (let i = messages.length - 1; i >= 0; i -= 1) {
     const message = messages[i];
     if (
@@ -297,11 +302,6 @@ export async function injectBackgroundJobBoard(
     );
     if (!textPart || isInternalInitiatorPart(textPart)) return;
 
-    if (state.strategy === 'checkpoint-compatible') {
-      injectCheckpointBoard(state, messages, message, reminder);
-      return;
-    }
-
     rememberInjectedTerminalJobs(state, message.info.sessionID);
     // Append the board as its own trailing message rather than mutating
     // an existing user message. In long tool loops the latest user
@@ -327,45 +327,71 @@ export async function injectBackgroundJobBoard(
 function injectCheckpointBoard(
   state: InjectionState,
   messages: unknown[],
-  message: MessageWithParts,
-  reminder: string,
 ): void {
-  const sessionID = message.info.sessionID;
-  if (!sessionID) return;
   const currentMessages = realMessages(messages, state.metadataKey);
+  const tailMessage = currentMessages.at(-1);
+  const sessionID = tailMessage?.info.sessionID;
+  if (!tailMessage || !sessionID || !state.shouldManageSession(sessionID)) {
+    return;
+  }
+
+  const triggeringMessage = currentMessages.findLast(
+    (message) =>
+      isUserMessageWithParts(message) && message.info.sessionID === sessionID,
+  );
+  const reminder = state.backgroundJobBoard.formatForPrompt(sessionID);
+  const textPart = triggeringMessage?.parts.find(
+    (part) => part.type === 'text' && typeof part.text === 'string',
+  );
+  const canCreateSnapshot =
+    triggeringMessage !== undefined &&
+    (!triggeringMessage.info.agent ||
+      triggeringMessage.info.agent === 'orchestrator') &&
+    textPart !== undefined &&
+    !isInternalInitiatorPart(textPart) &&
+    reminder !== undefined;
+
+  const replayBaseMessage = triggeringMessage ?? tailMessage;
   const snapshotState = updateBoardHistoryState(
     state,
     sessionID,
     currentMessages,
   );
-  const anchorKey = findMessageAnchorKey(currentMessages, message);
-  if (!anchorKey) return;
-
-  if (snapshotState.snapshots.at(-1)?.text !== reminder && reminder) {
-    const encodedSessionID = encodeURIComponent(sessionID);
-    const sequence = snapshotState.nextSnapshotSequence;
-    snapshotState.nextSnapshotSequence += 1;
-    if (snapshotState.snapshots.length >= state.maxRetainedSnapshots) {
-      // Deliberately start a new cache epoch at the configured boundary.
-      snapshotState.snapshots.length = 0;
+
+  if (canCreateSnapshot && reminder) {
+    const anchorKey = findLastMessageAnchorKey(currentMessages);
+    if (anchorKey && snapshotState.snapshots.at(-1)?.text !== reminder) {
+      const encodedSessionID = encodeURIComponent(sessionID);
+      const sequence = snapshotState.nextSnapshotSequence;
+      snapshotState.nextSnapshotSequence += 1;
+      if (snapshotState.snapshots.length >= state.maxRetainedSnapshots) {
+        // Deliberately start a new cache epoch at the configured boundary.
+        snapshotState.snapshots.length = 0;
+      }
+      snapshotState.snapshots.push({
+        anchorKey,
+        id: `oh-my-opencode-slim:background-job-board:${encodedSessionID}:${sequence}`,
+        text: reminder,
+      });
     }
-    snapshotState.snapshots.push({
-      anchorKey,
-      id: `oh-my-opencode-slim:background-job-board:${encodedSessionID}:${sequence}`,
-      text: reminder,
-    });
+    rememberInjectedTerminalJobs(state, sessionID);
   }
 
-  rememberInjectedTerminalJobs(state, sessionID);
   replayCheckpointBoard(
     messages,
-    message,
+    replayBaseMessage,
     sessionID,
     snapshotState,
     state.metadataKey,
   );
 }
 
+function findLastMessageAnchorKey(
+  messages: MessageWithParts[],
+): string | undefined {
+  return messageAnchorKeys(messages).at(-1);
+}
+
 function boardHistoryMessageSignature(message: MessageWithParts): string {
   const text = message.parts
     .filter(
@@ -444,26 +470,6 @@ function updateBoardHistoryState(
   return current;
 }
 
-function findMessageAnchorKey(
-  messages: MessageWithParts[],
-  message: MessageWithParts,
-): string | undefined {
-  const anchorKeys = messageAnchorKeys(messages);
-  const messageID = message.info.id;
-  if (messageID) {
-    const index = messages.findIndex(
-      (candidate) => candidate.info.id === messageID,
-    );
-    return index >= 0 ? anchorKeys[index] : undefined;
-  }
-
-  const signature = boardHistoryMessageSignature(message);
-  const index = messages.findLastIndex(
-    (candidate) => boardHistoryMessageSignature(candidate) === signature,
-  );
-  return index >= 0 ? anchorKeys[index] : undefined;
-}
-
 function createBoardMessage(
   baseMessage: MessageWithParts,
   sessionID: string,

+ 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', () => {