Răsfoiți Sursa

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

Daniel Maly 1 lună în urmă
părinte
comite
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
 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
 snapshot would exceed the configured limit, all retained snapshots are discarded
 and only the new current snapshot is kept. This intentionally creates one cache
 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);
     stripTaggedContent(messages, state.metadataKey);
   }
   }
 
 
+  if (state.strategy === 'checkpoint-compatible') {
+    injectCheckpointBoard(state, messages);
+    return;
+  }
+
   for (let i = messages.length - 1; i >= 0; i -= 1) {
   for (let i = messages.length - 1; i >= 0; i -= 1) {
     const message = messages[i];
     const message = messages[i];
     if (
     if (
@@ -297,11 +302,6 @@ export async function injectBackgroundJobBoard(
     );
     );
     if (!textPart || isInternalInitiatorPart(textPart)) return;
     if (!textPart || isInternalInitiatorPart(textPart)) return;
 
 
-    if (state.strategy === 'checkpoint-compatible') {
-      injectCheckpointBoard(state, messages, message, reminder);
-      return;
-    }
-
     rememberInjectedTerminalJobs(state, message.info.sessionID);
     rememberInjectedTerminalJobs(state, message.info.sessionID);
     // Append the board as its own trailing message rather than mutating
     // Append the board as its own trailing message rather than mutating
     // an existing user message. In long tool loops the latest user
     // an existing user message. In long tool loops the latest user
@@ -327,45 +327,71 @@ export async function injectBackgroundJobBoard(
 function injectCheckpointBoard(
 function injectCheckpointBoard(
   state: InjectionState,
   state: InjectionState,
   messages: unknown[],
   messages: unknown[],
-  message: MessageWithParts,
-  reminder: string,
 ): void {
 ): void {
-  const sessionID = message.info.sessionID;
-  if (!sessionID) return;
   const currentMessages = realMessages(messages, state.metadataKey);
   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(
   const snapshotState = updateBoardHistoryState(
     state,
     state,
     sessionID,
     sessionID,
     currentMessages,
     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(
   replayCheckpointBoard(
     messages,
     messages,
-    message,
+    replayBaseMessage,
     sessionID,
     sessionID,
     snapshotState,
     snapshotState,
     state.metadataKey,
     state.metadataKey,
   );
   );
 }
 }
 
 
+function findLastMessageAnchorKey(
+  messages: MessageWithParts[],
+): string | undefined {
+  return messageAnchorKeys(messages).at(-1);
+}
+
 function boardHistoryMessageSignature(message: MessageWithParts): string {
 function boardHistoryMessageSignature(message: MessageWithParts): string {
   const text = message.parts
   const text = message.parts
     .filter(
     .filter(
@@ -444,26 +470,6 @@ function updateBoardHistoryState(
   return current;
   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(
 function createBoardMessage(
   baseMessage: MessageWithParts,
   baseMessage: MessageWithParts,
   sessionID: string,
   sessionID: string,

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

@@ -10,17 +10,12 @@ describe('isTruthyEnvValue', () => {
     expect(isTruthyEnvValue(value)).toBe(true);
     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', () => {
 describe('isPluginDisabledByEnv', () => {