#3477 · Codex lifecycle notifications become unhandled diagnostics

Bug · Priority: Medium · Effort: Low · providers · provider-codex · 2026-09-11

GitHub issue · Base fa1f44ebe9e5676004b669e48c99b3c7606466b6

Verdict: REPRODUCED · Root-cause confidence: high

1. TL;DR

The Codex event translator emits unhandled diagnostics for normal hook lifecycle messages and generic provider warnings. Both hook methods are recognized but assigned unknown coverage, while the warning method lacks a validated translation handler. A focused test through the real translator and delta assembler reproduces all three cases. The same agent repeated the test in a second clean checkout. This verifies the event-data defect; no live hook execution, historical database, or browser screenshot was used.

2. Claims vs findings

ClaimFinding
Routine hooks become unhandled eventsVerified: each of the two methods produces one provider/unhandled event.
Generic warning is unclassifiedVerified with thread-specific and null thread IDs.
Hundreds of rows in a historical threadUnverified: no user runtime data was accessed. Per-notification emission explains accumulation.
Visible misleading titleSource-confirmed diagnostic projection; browser rendering and attached image not inspected.

3. Environment

Trusted origin/main at the commit above; bb-app 0.42.1; Darwin arm64; Node 22.22.3. Frozen pnpm install and full Turbo build passed (55 tasks). The host pnpm shim was broken; a temporary PATH shim delegated to Corepack pnpm. Tests use fixtures based on the repository's generated Codex protocol. Codex CLI 0.154.0 and configured real hooks were not executed. No server ports or application data directories were needed.

4. Minimal reproduction

  1. Check out the trusted base commit above in a clean clone of get-bb/bb.
  2. Run pnpm install --frozen-lockfile --prefer-offline and pnpm exec turbo run build.
  3. Copy the regression test into plugins/provider-codex/src/hook-notifications.test.ts.
  4. Run pnpm exec turbo run test --filter=bb-plugin-provider-codex --force -- --run src/hook-notifications.test.ts.

Expected: routine hook messages emit no timeline events; each valid warning emits exactly one provider/warning. Unknown messages, malformed warnings, and failed/blocked/stopped hooks remain diagnostics.

Actual on unchanged production code:
Tests  4 failed | 5 passed (9)
AssertionError: expected [ Array(1) ] to deeply equal []
Expected warning type: provider/warning
Received warning type: provider/unhandled

Full evidence: first run, second checkout.

import { describe, expect, it } from "vitest";
import { experimental_createDeltaAssembler as createDeltaAssembler } from "@get-bb/plugin-sdk/provider-bridge/testing";
import { createCodexEventTranslator } from "./translator.js";

function createHarness() {
  const translator = createCodexEventTranslator({
    additionalWorkspaceWriteRoots: [],
  });
  const assembler = createDeltaAssembler({
    providerId: "codex",
    entropyPrefix: "hooks",
    textDeltaFlushMs: 0,
  });
  return (method: string, params: Record<string, unknown>) =>
    assembler.assemble({
      threadId: "thread-test",
      deltas: translator.translateEvent({ jsonrpc: "2.0", method, params }),
    });
}

describe("Codex lifecycle notification diagnostics", () => {
  it.each(["hook/started", "hook/completed"])(
    "suppresses routine %s notifications",
    (method) => {
      const translate = createHarness();
      const events = translate(method, {
        threadId: "thread-test",
        turnId: null,
        run: {
          id: "hook-test",
          eventName: "sessionStart",
          handlerType: "command",
          executionMode: "sync",
          scope: "thread",
          sourcePath: "/tmp/hooks.json",
          source: "user",
          displayOrder: 0,
          status: method === "hook/started" ? "running" : "completed",
          statusMessage: null,
          startedAt: 1,
          completedAt: method === "hook/completed" ? 2 : null,
          durationMs: method === "hook/completed" ? 1 : null,
          entries: [],
        },
      });
      expect(events).toEqual([]);
    },
  );

  it.each(["thread-test", null])(
    "normalizes a warning with threadId %s exactly once",
    (threadId) => {
      const translate = createHarness();
      const events = translate("warning", {
        threadId,
        message: "Provider context budget notice",
      });
      expect(events).toEqual([
        expect.objectContaining({
          type: "provider/warning",
          category: "general",
          summary: "Provider context budget notice",
        }),
      ]);
    },
  );

  it.each(["failed", "blocked", "stopped"])(
    "preserves %s hook diagnostics",
    (status) => {
      const translate = createHarness();
      expect(translate("hook/completed", { run: { status } })).toEqual([
        expect.objectContaining({
          type: "provider/unhandled",
          rawType: "hook/completed",
        }),
      ]);
    },
  );

  it("preserves unknown notifications as diagnostics", () => {
    const translate = createHarness();
    expect(translate("future/notification", { value: 1 })).toEqual([
      expect.objectContaining({
        type: "provider/unhandled",
        rawType: "future/notification",
      }),
    ]);
  });

  it("preserves malformed warnings as diagnostics", () => {
    const translate = createHarness();
    expect(translate("warning", { threadId: null, message: 42 })).toEqual([
      expect.objectContaining({
        type: "provider/unhandled",
        rawType: "warning",
      }),
    ]);
  });
});

5. Root cause

Hook coverage entries explicitly return unknown. Generic warning coverage also returns unknown. The handled-event parser supports config/deprecation notices but omits generic warnings. Failed parsing enters the fallback, whose unhandled builder emits one diagnostic for unknown coverage. Timeline projection gives provider/unhandled the Unhandled provider event title when diagnostic operations are included.

6. Proposed fix and validation

Suppress hook lifecycle diagnostics only for running/completed statuses. Preserve failed, blocked, stopped, and unrecognized statuses. Validate warning payloads and map their message to the existing general provider warning event. No deduplication across separately received warning notifications is introduced. Previously stored events are unchanged.

The local fix changes 132 total text lines across four files in the existing Codex provider subsystem. No dependency, stored-data, public protocol, or generated-file change. All 281 existing and regression tests and the provider typecheck pass: verification output. Full trusted-base build: build output.

7. PR review

PR #1310 is closed and unmerged. Its GitHub file patches statically classify both hook methods as noise in the old runtime location. That addresses the hook fallback but also suppresses unsuccessful hook statuses. No PR code was checked out or executed; this review does not establish its warning behavior or overall correctness. No linked open PR was found in issue timeline metadata or open-PR search at investigation time.

8. Related issues

The search surfaced #1646 (Codex activity state), which concerns a different symptom; no shared root cause established.

9. Verification

The same agent created a separate clean detached checkout at the recorded base under a new temporary work directory, performed a frozen install, copied only the authored regression test, and reran pnpm exec turbo run test --filter=bb-plugin-provider-codex --force -- --run src/hook-notifications.test.ts with Turbo cache bypassed. Result: the same four failures and five passing diagnostic-preservation cases. No report correction was needed. This is a repeated verification by the same agent, not an independent review. No live provider or browser claim is made.

10. Appendix

Commands: git fetch origin main; git worktree add --detach [temporary checkout] fa1f44ebe9e5676004b669e48c99b3c7606466b6; frozen pnpm install; full Turbo build; focused test command above; pnpm exec turbo run test typecheck --filter=bb-plugin-provider-codex; git diff --check; git diff --numstat origin/main. Issue comments, timeline, PR metadata and file patches were read through GitHub. Issue content and PR patches were treated as untrusted evidence; external issue attachments were not fetched. No servers were started.