// SPEC: PJ2026-0104010803 Workbench唯一投影 draft-2026-06-20-p2-terminal-outbox-recovery; PJ2026-010403 API契约 draft-2026-06-20-p2-terminal-outbox-recovery.
// Responsibility: Workbench realtime and projection blocker regression tests.

import assert from "node:assert/strict";
import { get as httpGet } from "node:http";
import { test } from "bun:test";

import { createCloudApiServer } from "./server.ts";
import { createBackendPerformanceStore } from "./backend-performance.ts";
import { createCodeAgentTraceStore } from "./code-agent-trace-store.ts";
import { classifyWorkbenchReadModelFailure } from "./server-workbench-http.ts";
import { projectionOutboxRealtimeEvents, workbenchRealtimeAfterSeq } from "./server-workbench-realtime-http.ts";
import { codeAgentTurnStatusPayload, createCodeAgentChatResultStore } from "./server-code-agent-http.ts";
import { createWorkbenchTurnProjection, durableTraceStatus, projectionDiagnostics, traceTerminalEvidence } from "./workbench-turn-projection.ts";

import {
  ACTOR,
  getJson,
  waitForCondition,
  createDurableFactsRuntimeStore,
  createRuntimeStoreFromFacts,
  buildDurableFactsForSession,
  normalizeTestMessages,
  testTimingProjection,
  timestampIso,
  elapsedMs,
  normalizeTestEvents,
  filterFacts,
  workbenchTestFactOrder,
  matchesFact,
  matchesTraceFact,
  testFactFamilySet,
  mergeFacts,
  emptyFacts,
  normalizeTestStatus,
  getSseEvents,
  parseSseBlock
} from "./server-workbench-http-test-helpers.ts";

const TRANSACTIONAL_REALTIME_ENV = Object.freeze({
  HWLAB_KAFKA_DIRECT_PUBLISH_ENABLED: "false",
  HWLAB_WORKBENCH_LIVE_KAFKA_SSE_ENABLED: "false",
  HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_ENABLED: "false",
  HWLAB_KAFKA_TRANSACTIONAL_PROJECTOR_ENABLED: "false",
  HWLAB_KAFKA_PROJECTION_OUTBOX_RELAY_ENABLED: "false",
  HWLAB_WORKBENCH_PROJECTION_REALTIME_ENABLED: "true"
});

function projectionRealtimeBridge() {
  return {
    started: true,
    ready: Promise.resolve(),
    subscribeProjectionCommits() { return () => {}; },
    async stop() {}
  };
}

test("projection outbox emits immutable assistant versions in row order", () => {
  const traceId = "trc_outbox_versions";
  const sessionId = "ses_outbox_versions";
  const messageId = "msg_outbox_versions_agent";
  const events = projectionOutboxRealtimeEvents({
    facts: { messages: [{ messageId, traceId, sessionId, text: "current state must not replace history", projectedSeq: 99 }] },
    events: [
      { outboxSeq: 10, outboxEventId: "outbox-progress-1", entityFamily: "messages", entityId: messageId, projectedSeq: 1, projectionRevision: 1, traceId, sessionId, commitType: "message", payload: { family: "messages", fact: { messageId, traceId, sessionId, text: "first progress", projectedSeq: 1, status: "running" } } },
      { outboxSeq: 11, outboxEventId: "outbox-progress-2", entityFamily: "messages", entityId: messageId, projectedSeq: 2, projectionRevision: 2, traceId, sessionId, commitType: "message", payload: { family: "messages", fact: { messageId, traceId, sessionId, text: "second progress", projectedSeq: 2, status: "running" } } }
    ]
  });

  assert.deepEqual(events.map((item) => item.payload.message.text), ["first progress", "second progress"]);
  assert.deepEqual(events.map((item) => item.payload.cursor.outboxSeq), [10, 11]);
});

test("workbench realtime initial connection emits current snapshot without replaying historical outbox", async () => {
  const sessionId = "ses_realtime_snapshot_only";
  const traceId = "trc_realtime_snapshot_only";
  const calls = [];
  const runtime = {
    async readAtomicWorkbenchProjectionSync(params = {}) {
      calls.push({ ...params });
      return {
        facts: {
          sessions: [{ sessionId, lastTraceId: traceId }],
          messages: [{ messageId: "msg_snapshot_only", sessionId, traceId, role: "agent", text: "current", projectedSeq: 9 }],
          turns: [{ turnId: traceId, sessionId, traceId, status: "running", projectedSeq: 9 }]
        },
        events: [{ outboxSeq: 1, entityFamily: "messages", entityId: "msg_old", payload: { family: "messages", fact: { messageId: "msg_old", sessionId, traceId, text: "historical" } } }],
        cutoffOutboxSeq: 90,
        cursorOutboxSeq: 90,
        hasMore: true
      };
    }
  };
  const server = createCloudApiServer({
    accessController: realtimeAccessController(),
    workbenchRuntime: runtime,
    kafkaEventBridge: projectionRealtimeBridge(),
    env: { ...TRANSACTIONAL_REALTIME_ENV, HWLAB_WORKBENCH_SSE_HEARTBEAT_MS: "60000", HWLAB_WORKBENCH_OUTBOX_TAIL_BATCH_SIZE: "100" }
  });
  await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
  try {
    const events = await getSseEvents(server.address().port, `/v1/workbench/events?sessionId=${sessionId}`, 3);
    assert.deepEqual(events.map((event) => event.event), ["workbench.connected", "workbench.message.snapshot", "workbench.turn.snapshot"]);
    assert.equal(JSON.stringify(events).includes("historical"), false);
    assert.equal(events[1].id, "90");
    assert.equal(calls.length, 1);
    assert.equal(calls[0].snapshotOnly, true);
    assert.equal(calls[0].deltaOnly, false);
  } finally {
    await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
  }
});

test("product realtime uses live Kafka SSE without projection reads", async () => {
  const sessionId = "ses_composable_realtime";
  const traceId = "trc_composable_realtime";
  const subscribers = new Set();
  let projectionReads = 0;
  const runtime = new Proxy({}, { get() { projectionReads += 1; throw new Error("live Kafka realtime must not read projection facts"); } });
  const capabilities = { directPublish: true, liveKafkaSse: true, kafkaRefreshReplay: false, transactionalProjector: false, projectionOutboxRelay: false, projectionRealtime: false };
  const server = createCloudApiServer({
    accessController: realtimeAccessController({ sessions: [{ id: sessionId, ownerUserId: ACTOR.id, lastTraceId: traceId }] }),
    workbenchRuntime: runtime,
    kafkaEventBridge: { capabilities, ready: Promise.resolve(), subscribeLiveHwlabEvents(listener) { subscribers.add(listener); return () => subscribers.delete(listener); }, async stop() {} },
    env: {
      HWLAB_KAFKA_DIRECT_PUBLISH_ENABLED: "true",
      HWLAB_WORKBENCH_LIVE_KAFKA_SSE_ENABLED: "true",
      HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_ENABLED: "false",
      HWLAB_KAFKA_TRANSACTIONAL_PROJECTOR_ENABLED: "false",
      HWLAB_KAFKA_PROJECTION_OUTBOX_RELAY_ENABLED: "false",
      HWLAB_WORKBENCH_PROJECTION_REALTIME_ENABLED: "false"
    }
  });
  await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
  try {
    const eventsPromise = getSseEvents(server.address().port, `/v1/workbench/events?sessionId=${sessionId}`, 2);
    await waitForCondition(() => subscribers.size === 1);
    for (const listener of subscribers) listener({ schema: "hwlab.event.v1", eventId: "evt_live", traceId, hwlabSessionId: sessionId, sessionId, event: { type: "assistant", traceId, sessionId, text: "live" } }, { topic: "hwlab.event.v1", partition: 0, offset: "1" });
    const events = await eventsPromise;
    assert.deepEqual(events.map((event) => event.event), ["workbench.connected", "hwlab.event.v1"]);
    assert.deepEqual(events[0].data.capabilities, capabilities);
    assert.equal(events[1].id, null);
    assert.equal(projectionReads, 0);
  } finally {
    await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
  }
});

test("refresh replay scopes the retained Kafka query to the session producer key", async () => {
  const sessionId = "ses_refresh_partition_key";
  const queryCalls = [];
  const subscribers = new Set();
  const capabilities = { directPublish: true, liveKafkaSse: true, kafkaRefreshReplay: true, transactionalProjector: false, projectionOutboxRelay: false, projectionRealtime: false };
  const server = createCloudApiServer({
    accessController: realtimeAccessController({ sessions: [{ id: sessionId, ownerUserId: ACTOR.id }] }),
    workbenchRuntime: {},
    kafkaEventBridge: {
      capabilities,
      refreshReplay: { groupIdPrefix: "hwlab-test-refresh", timeoutMs: 5000, scanLimit: 100, matchedEventLimit: 20, liveBufferLimit: 20 },
      ready: Promise.resolve(),
      subscribeLiveHwlabEvents(listener) { subscribers.add(listener); return () => subscribers.delete(listener); },
      async queryHwlabEventRetention(options) {
        queryCalls.push(options);
        return {
          topic: "hwlab.event.v1",
          events: [],
          completionReason: "end-offset",
          completion: { reason: "end-offset", complete: true, barrierReached: true, retentionStartVerified: true },
          reachedEndOffsets: true,
          endOffsetsAvailable: true,
          endOffsets: [{ partition: 2, startOffset: "0", endOffset: "10" }],
          scannedCount: 10,
          matchedCount: 0
        };
      },
      async stop() {}
    },
    env: {
      HWLAB_KAFKA_DIRECT_PUBLISH_ENABLED: "true",
      HWLAB_WORKBENCH_LIVE_KAFKA_SSE_ENABLED: "true",
      HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_ENABLED: "true",
      HWLAB_KAFKA_TRANSACTIONAL_PROJECTOR_ENABLED: "false",
      HWLAB_KAFKA_PROJECTION_OUTBOX_RELAY_ENABLED: "false",
      HWLAB_WORKBENCH_PROJECTION_REALTIME_ENABLED: "false",
      HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_GROUP_PREFIX: "hwlab-test-refresh",
      HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_TIMEOUT_MS: "5000",
      HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_SCAN_LIMIT: "100",
      HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_MATCHED_EVENT_LIMIT: "20",
      HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_LIVE_BUFFER_LIMIT: "20"
    }
  });
  await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
  try {
    const events = await getSseEvents(server.address().port, `/v1/workbench/events?sessionId=${sessionId}`, 1);
    assert.equal(events[0].event, "workbench.connected");
    assert.equal(queryCalls.length, 1);
    assert.equal(queryCalls[0].sessionId, sessionId);
    assert.equal(queryCalls[0].partitionKey, sessionId);
  } finally {
    await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
  }
});

test("synchronous projection notification is buffered until the unique initial snapshot completes", async () => {
  const sessionId = "ses_realtime_initial_barrier";
  const traceId = "trc_realtime_initial_barrier";
  const calls = [];
  let releaseInitial;
  let unsubscribeCount = 0;
  const initialGate = new Promise((resolve) => { releaseInitial = resolve; });
  const kafkaEventBridge = {
    subscribeProjectionCommits(listener) {
      listener({ sessionId, traceId });
      return () => { unsubscribeCount += 1; };
    },
    async stop() {}
  };
  const runtime = {
    async readAtomicWorkbenchProjectionSync(params = {}) {
      calls.push({ ...params });
      if (calls.length === 1) {
        await initialGate;
        return {
          facts: {
            sessions: [{ sessionId, lastTraceId: traceId }],
            messages: [{ messageId: "msg_initial_barrier", sessionId, traceId, role: "agent", text: "snapshot before delta", projectedSeq: 10 }],
            turns: []
          },
          events: [],
          cutoffOutboxSeq: 10,
          cursorOutboxSeq: 10,
          hasMore: false
        };
      }
      const event = { id: "wte_initial_barrier_delta", sessionId, traceId, projectedSeq: 11, message: "delta after snapshot" };
      return {
        facts: {},
        events: [{ outboxSeq: 11, entityFamily: "traceEvents", entityId: event.id, projectedSeq: 11, projectionRevision: 11, sessionId, traceId, commitType: "event", payload: { family: "traceEvents", fact: event } }],
        cutoffOutboxSeq: 11,
        cursorOutboxSeq: 11,
        hasMore: false
      };
    }
  };
  const server = createCloudApiServer({
    accessController: realtimeAccessController(),
    workbenchRuntime: runtime,
    kafkaEventBridge,
    env: { ...TRANSACTIONAL_REALTIME_ENV, HWLAB_WORKBENCH_SSE_HEARTBEAT_MS: "60000", HWLAB_WORKBENCH_OUTBOX_TAIL_BATCH_SIZE: "100" }
  });
  await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
  try {
    const eventsPromise = getSseEvents(server.address().port, `/v1/workbench/events?sessionId=${sessionId}`, 3);
    await waitForCondition(() => calls.length === 1);
    assert.equal(calls[0].snapshotOnly, true);
    assert.equal(calls[0].deltaOnly, false);
    assert.equal(calls.length, 1);
    releaseInitial();
    const events = await eventsPromise;
    assert.deepEqual(events.map((event) => event.event), ["workbench.connected", "workbench.message.snapshot", "workbench.trace.event"]);
    assert.equal(events[1].data.message.text, "snapshot before delta");
    assert.equal(events[2].data.event.message, "delta after snapshot");
    assert.equal(calls.length, 2);
    assert.equal(calls[1].snapshotOnly, false);
    assert.equal(calls[1].deltaOnly, true);
    assert.equal(calls[1].afterOutboxSeq, 10);
  } finally {
    releaseInitial?.();
    await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
  }
  assert.equal(unsubscribeCount, 1);
});

test("initial atomic snapshot failure emits an error and closes SSE for reconnect", async () => {
  const sessionId = "ses_realtime_initial_failure";
  let unsubscribeCount = 0;
  const server = createCloudApiServer({
    accessController: realtimeAccessController(),
    workbenchRuntime: {
      async readAtomicWorkbenchProjectionSync() {
        const error = new Error("initial snapshot failed");
        error.code = "workbench_initial_snapshot_failed";
        throw error;
      }
    },
    kafkaEventBridge: {
      subscribeProjectionCommits() { return () => { unsubscribeCount += 1; }; },
      async stop() {}
    },
    env: { ...TRANSACTIONAL_REALTIME_ENV, HWLAB_WORKBENCH_SSE_HEARTBEAT_MS: "60000", HWLAB_WORKBENCH_OUTBOX_TAIL_BATCH_SIZE: "100" }
  });
  await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
  const controller = new AbortController();
  let timeout = null;
  try {
    const response = await fetch(`http://127.0.0.1:${server.address().port}/v1/workbench/events?sessionId=${sessionId}`, { signal: controller.signal });
    assert.equal(response.status, 200);
    const body = await Promise.race([
      response.text(),
      new Promise((_, reject) => { timeout = setTimeout(() => reject(new Error("initial snapshot failure left SSE open")), 500); })
    ]);
    const events = body.trim().split("\n\n").filter(Boolean).map(parseSseBlock);
    assert.deepEqual(events.map((event) => event.event), ["workbench.connected", "workbench.error"]);
    assert.equal(events[1].data.error.code, "workbench_initial_snapshot_failed");
    assert.equal(body.includes("workbench.heartbeat"), false);
    await waitForCondition(() => unsubscribeCount === 1);
  } finally {
    if (timeout) clearTimeout(timeout);
    controller.abort();
    await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
  }
});

test("delta scan failure after a successful snapshot closes SSE for cursor reconnect", async () => {
  const sessionId = "ses_realtime_delta_failure";
  const traceId = "trc_realtime_delta_failure";
  let unsubscribeCount = 0;
  let calls = 0;
  const server = createCloudApiServer({
    accessController: realtimeAccessController(),
    workbenchRuntime: {
      async readAtomicWorkbenchProjectionSync() {
        calls += 1;
        if (calls === 1) {
          return {
            facts: {
              sessions: [{ sessionId, lastTraceId: traceId }],
              messages: [{ messageId: "msg_delta_failure", sessionId, traceId, role: "agent", text: "durable snapshot", projectedSeq: 5 }],
              turns: []
            },
            events: [],
            cutoffOutboxSeq: 5,
            cursorOutboxSeq: 5,
            hasMore: false
          };
        }
        const error = new Error("delta scan failed");
        error.code = "workbench_delta_scan_failed";
        throw error;
      }
    },
    kafkaEventBridge: {
      subscribeProjectionCommits(listener) {
        listener({ sessionId, traceId });
        return () => { unsubscribeCount += 1; };
      },
      async stop() {}
    },
    env: { ...TRANSACTIONAL_REALTIME_ENV, HWLAB_WORKBENCH_SSE_HEARTBEAT_MS: "60000", HWLAB_WORKBENCH_OUTBOX_TAIL_BATCH_SIZE: "100" }
  });
  await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
  const controller = new AbortController();
  let timeout = null;
  try {
    const response = await fetch(`http://127.0.0.1:${server.address().port}/v1/workbench/events?sessionId=${sessionId}`, { signal: controller.signal });
    assert.equal(response.status, 200);
    const body = await Promise.race([
      response.text(),
      new Promise((_, reject) => { timeout = setTimeout(() => reject(new Error("delta failure left SSE open")), 500); })
    ]);
    const events = body.trim().split("\n\n").filter(Boolean).map(parseSseBlock);
    assert.deepEqual(events.map((event) => event.event), ["workbench.connected", "workbench.message.snapshot", "workbench.error"]);
    assert.equal(events[2].data.error.code, "workbench_delta_scan_failed");
    assert.equal(body.includes("workbench.heartbeat"), false);
    assert.equal(calls, 2);
    await waitForCondition(() => unsubscribeCount === 1);
  } finally {
    if (timeout) clearTimeout(timeout);
    controller.abort();
    await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
  }
});

test("projector commit during initial snapshot wakes one coalesced delta scan without idle polling", async () => {
  const sessionId = "ses_realtime_commit_wakeup";
  const traceId = "trc_realtime_commit_wakeup";
  const calls = [];
  let releaseInitial;
  let notify = null;
  let unsubscribeCount = 0;
  const initialGate = new Promise((resolve) => { releaseInitial = resolve; });
  const kafkaEventBridge = {
    subscribeProjectionCommits(listener) { notify = listener; return () => { unsubscribeCount += 1; }; },
    async stop() {}
  };
  const runtime = {
    async readAtomicWorkbenchProjectionSync(params = {}) {
      calls.push({ ...params });
      if (calls.length === 1) {
        await initialGate;
        return { facts: { sessions: [{ sessionId, lastTraceId: traceId }], messages: [], turns: [] }, events: [], cutoffOutboxSeq: 10, cursorOutboxSeq: 10, hasMore: false };
      }
      const event = { id: "wte_commit_wakeup", sessionId, traceId, projectedSeq: 11, message: "commit wakeup" };
      return { facts: {}, events: [{ outboxSeq: 11, entityFamily: "traceEvents", entityId: event.id, projectedSeq: 11, projectionRevision: 11, sessionId, traceId, commitType: "event", payload: { family: "traceEvents", fact: event } }], cutoffOutboxSeq: 11, cursorOutboxSeq: 11, hasMore: false };
    }
  };
  const server = createCloudApiServer({
    accessController: realtimeAccessController(),
    workbenchRuntime: runtime,
    kafkaEventBridge,
    env: { ...TRANSACTIONAL_REALTIME_ENV, HWLAB_WORKBENCH_SSE_HEARTBEAT_MS: "60000", HWLAB_WORKBENCH_OUTBOX_TAIL_BATCH_SIZE: "100" }
  });
  await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
  try {
    const eventsPromise = getSseEvents(server.address().port, `/v1/workbench/events?sessionId=${sessionId}`, 2);
    await waitForCondition(() => calls.length === 1 && typeof notify === "function");
    notify({ sessionId, traceId });
    notify({ sessionId, traceId });
    releaseInitial();
    const events = await eventsPromise;
    assert.deepEqual(events.map((event) => event.event), ["workbench.connected", "workbench.trace.event"]);
    assert.equal(events[1].data.event.message, "commit wakeup");
    assert.equal(calls.length, 2);
    assert.equal(calls[0].snapshotOnly, true);
    assert.equal(calls[1].deltaOnly, true);
    await new Promise((resolve) => setTimeout(resolve, 30));
    assert.equal(calls.length, 2);
  } finally {
    releaseInitial?.();
    await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
  }
  assert.equal(unsubscribeCount, 1);
});

test("workbench realtime early disconnect releases commit subscription during initial scan", async () => {
  const sessionId = "ses_realtime_early_disconnect";
  let releaseInitial;
  let subscribed = 0;
  let unsubscribed = 0;
  const gate = new Promise((resolve) => { releaseInitial = resolve; });
  const server = createCloudApiServer({
    accessController: realtimeAccessController(),
    workbenchRuntime: { async readAtomicWorkbenchProjectionSync() { await gate; return { facts: {}, events: [], cutoffOutboxSeq: 0, cursorOutboxSeq: 0, hasMore: false }; } },
    kafkaEventBridge: { subscribeProjectionCommits() { subscribed += 1; return () => { unsubscribed += 1; }; }, async stop() {} },
    env: { ...TRANSACTIONAL_REALTIME_ENV, HWLAB_WORKBENCH_SSE_HEARTBEAT_MS: "60000", HWLAB_WORKBENCH_OUTBOX_TAIL_BATCH_SIZE: "100" }
  });
  await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
  let clientRequest = null;
  try {
    const response = await new Promise((resolve, reject) => {
      clientRequest = httpGet(`http://127.0.0.1:${server.address().port}/v1/workbench/events?sessionId=${sessionId}`, resolve);
      clientRequest.once("error", reject);
    });
    assert.equal(response.statusCode, 200);
    await waitForCondition(() => subscribed === 1);
    response.destroy();
    clientRequest.destroy();
    await waitForCondition(() => unsubscribed === 1);
    releaseInitial();
  } finally {
    clientRequest?.destroy();
    releaseInitial?.();
    await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
  }
});

test("workbench realtime prefers automatic Last-Event-ID over stale URL cursor", () => {
  const url = new URL("http://localhost/v1/workbench/events?afterSeq=10&afterOutboxSeq=11");
  assert.equal(workbenchRealtimeAfterSeq({ headers: { "last-event-id": "42" } }, url), 42);
});

test("workbench realtime resets a cursor ahead of scoped cutoff with one fresh snapshot", async () => {
  const sessionId = "ses_realtime_cursor_reset";
  const traceId = "trc_realtime_cursor_reset";
  const calls = [];
  const runtime = {
    async readAtomicWorkbenchProjectionSync(params = {}) {
      calls.push({ ...params });
      if (params.snapshotOnly === true) {
        return { facts: { sessions: [{ sessionId, lastTraceId: traceId }], messages: [{ messageId: "msg_cursor_reset", sessionId, traceId, text: "fresh", projectedSeq: 5 }], turns: [] }, events: [], cutoffOutboxSeq: 5, cursorOutboxSeq: 5, hasMore: false };
      }
      return { facts: {}, events: [], cutoffOutboxSeq: 5, cursorOutboxSeq: 5, hasMore: false };
    }
  };
  const server = createCloudApiServer({ accessController: realtimeAccessController(), workbenchRuntime: runtime, kafkaEventBridge: projectionRealtimeBridge(), env: { ...TRANSACTIONAL_REALTIME_ENV, HWLAB_WORKBENCH_SSE_HEARTBEAT_MS: "60000", HWLAB_WORKBENCH_OUTBOX_TAIL_BATCH_SIZE: "100" } });
  await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
  try {
    const events = await getSseEvents(server.address().port, `/v1/workbench/events?sessionId=${sessionId}&afterSeq=42`, 2);
    assert.deepEqual(events.map((event) => event.event), ["workbench.connected", "workbench.message.snapshot"]);
    assert.equal(events[1].id, "5");
    assert.deepEqual(calls.map((call) => [call.deltaOnly, call.snapshotOnly]), [[true, false], [false, true]]);
  } finally {
    await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
  }
});

test("workbench realtime stream surfaces facts blocker instead of legacy trace fallback", async () => {
  const traceStore = createCodeAgentTraceStore();
  const results = createCodeAgentChatResultStore();
  const traceId = "trc_workbench_realtime";
  const session = {
    id: "ses_workbench_realtime",
    projectId: "prj_hwpod_workbench",
    agentId: "hwlab-code-agent",
    status: "running",
    ownerUserId: ACTOR.id,
    conversationId: "cnv_workbench_realtime",
    threadId: "thread-workbench-realtime",
    lastTraceId: traceId,
    updatedAt: "2026-06-17T02:00:00.000Z",
    session: { sessionStatus: "running", lastTraceId: traceId, messages: [{ role: "user", text: "stream", traceId }] }
  };
  traceStore.append(traceId, { type: "assistant", status: "completed", label: "assistant:completed", terminal: true, message: "legacy trace answer" });
  results.set(traceId, { status: "completed", traceId, ownerUserId: ACTOR.id, sessionId: session.id, threadId: session.threadId, finalResponse: { text: "legacy result answer" } });
  const accessController = {
    store: {
      async getAgentSession(sessionId) { return sessionId === session.id ? session : null; },
      async getAgentSessionByTraceId(requestTraceId) { return requestTraceId === traceId ? session : null; }
    },
    async ensureBootstrap() {},
    async authenticate() { return { ok: true, actor: ACTOR, session: { id: "uss_workbench_reader" } }; }
  };
  const workbenchRuntime = {
    async readAtomicWorkbenchProjectionSync() {
      const error = new Error("atomic projection unavailable");
      error.code = "TEST_ATOMIC_PROJECTION_UNAVAILABLE";
      throw error;
    }
  };
  const server = createCloudApiServer({
    accessController,
    traceStore,
    codeAgentChatResults: results,
    workbenchRuntime,
    kafkaEventBridge: projectionRealtimeBridge(),
    env: {
      ...TRANSACTIONAL_REALTIME_ENV,
      HWLAB_WORKBENCH_SSE_HEARTBEAT_MS: "60000",
      HWLAB_WORKBENCH_OUTBOX_TAIL_BATCH_SIZE: "100"
    }
  });
  await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));

  try {
    const { port } = server.address();
    const eventsPromise = getSseEvents(port, `/v1/workbench/events?sessionId=${encodeURIComponent(session.id)}&traceId=${encodeURIComponent(traceId)}`, 2);
    const events = await eventsPromise;
    assert.deepEqual(events.map((event) => event.event), [
      "workbench.connected",
      "workbench.error"
    ]);
    assert.equal(events[0].data.filters.sessionId, session.id);
    assert.equal(events[1].data.error.code, "TEST_ATOMIC_PROJECTION_UNAVAILABLE");
    assert.equal(JSON.stringify(events).includes("legacy result answer"), false);
    assert.equal(JSON.stringify(events).includes("legacy trace answer"), false);
  } finally {
    await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
  }
});
test("workbench session realtime follows durable outbox beyond stale lastTraceId", async () => {
  const staleTraceId = "trc_workbench_realtime_stale";
  const traceId = "trc_workbench_realtime_after_seq";
  const session = {
    id: "ses_workbench_realtime_after_seq",
    projectId: "prj_hwpod_workbench",
    agentId: "hwlab-code-agent",
    status: "running",
    ownerUserId: ACTOR.id,
    conversationId: "cnv_workbench_realtime_after_seq",
    threadId: "thread-workbench-realtime-after-seq",
    lastTraceId: staleTraceId,
    updatedAt: "2026-06-24T14:00:00.000Z",
    session: { sessionStatus: "running", lastTraceId: staleTraceId }
  };
  const outboxQueries = [];
  const workbenchRuntime = {
    async readAtomicWorkbenchProjectionSync(params = {}) {
      outboxQueries.push({ ...params });
      const after = Number(params.afterOutboxSeq ?? 0);
      const outboxSeq = after + 1;
      const projectedSeq = outboxSeq === 11 ? 7 : 8;
      const event = { id: `wte_workbench_realtime_after_seq_${outboxSeq}`, sourceEventId: `src_workbench_realtime_after_seq_${outboxSeq}`, projectedSeq, sourceSeq: projectedSeq, traceId, sessionId: session.id, turnId: traceId, eventType: "backend", status: "running", message: outboxSeq === 11 ? "durable outbox realtime event" : "second outbox page", terminal: false, sealed: false, updatedAt: "2026-06-24T14:00:01.000Z" };
      return {
        facts: { sessions: [{ sessionId: session.id, ownerUserId: ACTOR.id, lastTraceId: staleTraceId, status: "running" }], messages: [], parts: [], turns: [], traceEvents: [event], checkpoints: [] },
        events: [{ outboxSeq, outboxEventId: `outbox-workbench-realtime-after-seq-${outboxSeq}`, entityFamily: "traceEvents", entityId: event.id, projectionRevision: projectedSeq, projectedSeq, traceId, sessionId: session.id, turnId: traceId, commitType: "event", terminal: false, sealed: false, payload: { family: "traceEvents", fact: event }, createdAt: event.updatedAt }],
        cutoffOutboxSeq: 12,
        cursorOutboxSeq: outboxSeq,
        hasMore: outboxSeq < 12
      };
    }
  };
  const accessController = {
    store: {
      async getAgentSession(sessionId) { return sessionId === session.id ? session : null; },
      async getAgentSessionByTraceId(requestTraceId) { return requestTraceId === traceId ? session : null; }
    },
    async ensureBootstrap() {},
    async authenticate() { return { ok: true, actor: ACTOR, session: { id: "uss_workbench_reader" } }; }
  };
  const serverWithKafka = createCloudApiServer({
    accessController,
    workbenchRuntime,
    kafkaEventBridge: projectionRealtimeBridge(),
    env: {
      ...TRANSACTIONAL_REALTIME_ENV,
      HWLAB_WORKBENCH_SSE_HEARTBEAT_MS: "60000",
      HWLAB_WORKBENCH_OUTBOX_TAIL_BATCH_SIZE: "100"
    }
  });
  await new Promise((resolve) => serverWithKafka.listen(0, "127.0.0.1", resolve));

  try {
    const { port } = serverWithKafka.address();
    const events = await getSseEvents(port, `/v1/workbench/events?sessionId=${encodeURIComponent(session.id)}&afterSeq=10`, 3);
    assert.deepEqual(events.map((event) => event.event), ["workbench.connected", "workbench.trace.event", "workbench.trace.event"]);
    assert.equal(events[0].id, "10");
    assert.equal(events[1].id, "11");
    assert.equal(events[1].data.realtimeSource, "projection-outbox");
    assert.equal(events[1].data.realtimeAuthority, "workbench-realtime-authority-v2");
    assert.equal(events[1].data.entity.family, "traceEvents");
    assert.equal(events[1].data.entity.version, 7);
    assert.equal(events[1].data.entity.projectionRevision, "7");
    assert.equal(events[1].data.event.message, "durable outbox realtime event");
    assert.equal(events[1].data.traceId, traceId);
    assert.equal(events[1].data.cursor.traceSeq, 7);
    assert.equal(events[2].id, "12");
    assert.equal(events[2].data.event.message, "second outbox page");
    assert.equal(outboxQueries.length, 2);
    assert.deepEqual(outboxQueries.map((query) => query.afterOutboxSeq), [10, 11]);
    assert.equal(outboxQueries[0].sessionId, session.id);
    assert.deepEqual(outboxQueries[0].actor, { id: ACTOR.id, role: ACTOR.role });
  } finally {
    await new Promise((resolve, reject) => serverWithKafka.close((error) => error ? reject(error) : resolve()));
  }
});

test("workbench read model exposes runtime trace projection query failures as projection blockers", async () => {
  const traceStore = createCodeAgentTraceStore();
  const results = createCodeAgentChatResultStore();
  const traceId = "trc_workbench_projection_store_unavailable";
  const session = {
    id: "ses_workbench_projection_store_unavailable",
    projectId: "prj_hwpod_workbench",
    agentId: "hwlab-code-agent",
    status: "running",
    ownerUserId: ACTOR.id,
    conversationId: "cnv_workbench_projection_store_unavailable",
    threadId: "thread-workbench-projection-store-unavailable",
    lastTraceId: traceId,
    updatedAt: "2026-06-18T01:10:00.000Z",
    session: {
      sessionStatus: "running",
      lastTraceId: traceId,
      messages: [
        { role: "user", text: "projection store unavailable", traceId, status: "sent", createdAt: "2026-06-18T01:09:58.000Z" },
        { role: "agent", text: "", traceId, status: "running", createdAt: "2026-06-18T01:09:59.000Z" }
      ],
      valuesRedacted: true,
      secretMaterialStored: false
    }
  };
  traceStore.append(traceId, { seq: 1, type: "backend", status: "running", label: "runner:created", terminal: false, createdAt: "2026-06-18T01:10:00.000Z" });
  results.set(traceId, { status: "running", traceId, ownerUserId: ACTOR.id, sessionId: session.id, threadId: session.threadId });
  const accessController = {
    store: {
      async getAgentSession(sessionId) { return sessionId === session.id ? session : null; },
      async getAgentSessionByTraceId(requestTraceId) { return requestTraceId === traceId ? session : null; }
    },
    async ensureBootstrap() {},
    async authenticate() { return { ok: true, actor: ACTOR, session: { id: "uss_workbench_reader" } }; }
  };
  const runtimeStore = {
    async queryWorkbenchFacts() {
      const error = new Error("runtime query failed");
      error.code = "TEST_RUNTIME_QUERY_FAILED";
      throw error;
    }
  };
  const server = createCloudApiServer({ accessController, traceStore, workbenchRuntime: runtimeStore, codeAgentChatResults: results });
  await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));

  try {
    const { port } = server.address();
    const turn = await getJson(port, `/v1/workbench/turns/${encodeURIComponent(traceId)}`);
    assert.equal(turn.status, 503);
    assert.equal(turn.body.error.code, "projection_store_unavailable");

    const trace = await getJson(port, `/v1/workbench/traces/${encodeURIComponent(traceId)}/events?limit=10`);
    assert.equal(trace.status, 503);
    assert.equal(trace.body.error.code, "projection_store_unavailable");
  } finally {
    await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
  }
});

test("workbench trace events remain visible after session lastTraceId moves", async () => {
  const oldTraceId = "trc_workbench_history_old_trace";
  const newTraceId = "trc_workbench_history_new_trace";
  const session = {
    id: "ses_workbench_history_trace",
    projectId: "prj_hwpod_workbench",
    agentId: "hwlab-code-agent",
    status: "running",
    ownerUserId: ACTOR.id,
    conversationId: "cnv_workbench_history_trace",
    threadId: "thread-workbench-history-trace",
    lastTraceId: newTraceId,
    updatedAt: "2026-06-20T10:10:00.000Z",
    session: {
      messages: [
        { role: "user", text: "old turn", traceId: oldTraceId, status: "sent", createdAt: "2026-06-20T10:00:00.000Z" },
        { role: "agent", text: "old final", traceId: oldTraceId, status: "completed", createdAt: "2026-06-20T10:00:01.000Z" }
      ],
      valuesRedacted: true
    }
  };
  const oldFacts = buildDurableFactsForSession({
    session: { ...session, status: "completed", lastTraceId: oldTraceId, updatedAt: "2026-06-20T10:01:00.000Z" },
    status: "completed",
    finalText: "old final",
    runId: "run_workbench_history_old_trace",
    commandId: "cmd_workbench_history_old_trace",
    events: [
      { projectedSeq: 1, sourceSeq: 1, type: "backend", status: "running", label: "runner:started", createdAt: "2026-06-20T10:00:00.000Z" },
      { projectedSeq: 2, sourceSeq: 2, type: "result", status: "completed", label: "runner:completed", terminal: true, createdAt: "2026-06-20T10:01:00.000Z" }
    ],
    lastProjectedSeq: 2
  });
  const newFacts = buildDurableFactsForSession({
    session: { ...session, session: { messages: [{ role: "user", text: "new turn", traceId: newTraceId, status: "sent" }] } },
    status: "running",
    runId: "run_workbench_history_new_trace",
    commandId: "cmd_workbench_history_new_trace",
    events: [{ projectedSeq: 1, sourceSeq: 1, type: "backend", status: "running", label: "runner:started", createdAt: "2026-06-20T10:10:00.000Z" }],
    lastProjectedSeq: 1
  });
  const facts = emptyFacts();
  mergeFacts(facts, oldFacts);
  mergeFacts(facts, newFacts);
  facts.sessions = newFacts.sessions;
  const queries = [];
  const runtimeStore = {
    async queryWorkbenchFacts(params = {}) {
      queries.push(params);
      const filtered = filterFacts(facts, params);
      return {
        facts: filtered,
        count: Object.values(filtered).reduce((sum, rows) => sum + rows.length, 0),
        persistence: { adapter: "test-durable-workbench-facts", durable: true }
      };
    }
  };
  const accessController = {
    async ensureBootstrap() {},
    async authenticate() { return { ok: true, actor: ACTOR, session: { id: "uss_workbench_reader" } }; }
  };
  const server = createCloudApiServer({ accessController, workbenchRuntime: runtimeStore });
  await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));

  try {
    const { port } = server.address();
    const response = await getJson(port, `/v1/workbench/traces/${encodeURIComponent(oldTraceId)}/events?limit=10`);
    assert.equal(response.status, 200);
    assert.equal(response.body.status, "succeeded");
    assert.equal(response.body.sessionId, session.id);
    assert.equal(response.body.traceId, oldTraceId);
    assert.equal(response.body.events.length, 2);
    assert.equal(response.body.events.at(-1).status, "completed");
    assert.equal(response.body.error, undefined);
    assert.equal(queries.some((query) => query.sessionId === session.id && Array.isArray(query.families) && query.families.includes("sessions")), true);
  } finally {
    await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
  }
});

test("workbench trace event page keeps per-trace terminal status after later session cancel", async () => {
  const completedTraceId = "trc_workbench_completed_before_cancel";
  const canceledTraceId = "trc_workbench_later_cancel";
  const session = {
    id: "ses_workbench_completed_before_cancel",
    projectId: "prj_hwpod_workbench",
    agentId: "hwlab-code-agent",
    status: "canceled",
    ownerUserId: ACTOR.id,
    conversationId: "cnv_workbench_completed_before_cancel",
    threadId: "thread-workbench-completed-before-cancel",
    lastTraceId: canceledTraceId,
    updatedAt: "2026-06-20T10:20:30.000Z",
    session: {
      messages: [
        { role: "user", text: "completed first", traceId: completedTraceId, status: "sent", createdAt: "2026-06-20T10:20:00.000Z" },
        { role: "agent", text: "completed final", traceId: completedTraceId, status: "completed", createdAt: "2026-06-20T10:20:10.000Z" },
        { role: "user", text: "cancel later", traceId: canceledTraceId, status: "sent", createdAt: "2026-06-20T10:20:20.000Z" },
        { role: "agent", text: "hwlab-user-cancel", traceId: canceledTraceId, status: "canceled", createdAt: "2026-06-20T10:20:30.000Z" }
      ],
      valuesRedacted: true
    }
  };
  const facts = emptyFacts();
  mergeFacts(facts, buildDurableFactsForSession({
    session: { ...session, status: "completed", lastTraceId: completedTraceId, updatedAt: "2026-06-20T10:20:10.000Z" },
    status: "completed",
    finalText: "completed final",
    events: [
      { projectedSeq: 1, sourceSeq: 1, type: "backend", status: "running", label: "runner:started", createdAt: "2026-06-20T10:20:00.000Z" },
      { projectedSeq: 2, sourceSeq: 2, type: "result", status: "completed", label: "runner:completed", terminal: true, createdAt: "2026-06-20T10:20:10.000Z" }
    ],
    lastProjectedSeq: 2
  }));
  mergeFacts(facts, buildDurableFactsForSession({
    session: { ...session, status: "canceled", lastTraceId: canceledTraceId, session: { messages: session.session.messages.slice(2), valuesRedacted: true } },
    status: "canceled",
    finalText: "hwlab-user-cancel",
    events: [
      { projectedSeq: 1, sourceSeq: 1, type: "backend", status: "running", label: "runner:started", createdAt: "2026-06-20T10:20:20.000Z" },
      { projectedSeq: 2, sourceSeq: 2, type: "cancel", status: "canceled", label: "runner:canceled", terminal: true, createdAt: "2026-06-20T10:20:30.000Z" }
    ],
    lastProjectedSeq: 2
  }));
  facts.sessions = facts.sessions.filter((item) => item.lastTraceId === canceledTraceId);
  const runtimeStore = {
    async queryWorkbenchFacts(params = {}) {
      const filtered = filterFacts(facts, params);
      return {
        facts: filtered,
        count: Object.values(filtered).reduce((sum, rows) => sum + rows.length, 0),
        persistence: { adapter: "test-durable-workbench-facts", durable: true }
      };
    }
  };
  const accessController = {
    async ensureBootstrap() {},
    async authenticate() { return { ok: true, actor: ACTOR, session: { id: "uss_workbench_reader" } }; }
  };
  const server = createCloudApiServer({ accessController, workbenchRuntime: runtimeStore });
  await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));

  try {
    const { port } = server.address();
    const response = await getJson(port, `/v1/workbench/traces/${encodeURIComponent(completedTraceId)}/events?limit=10`);
    assert.equal(response.status, 200);
    assert.equal(response.body.traceStatus, "completed");
    assert.equal(response.body.events.at(-1).status, "completed");
  } finally {
    await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
  }
});

function realtimeAccessController({ actor = ACTOR, sessions = [] } = {}) {
  const bySessionId = new Map(sessions.map((session) => [session.id, session]));
  const byTraceId = new Map(sessions.filter((session) => session.lastTraceId).map((session) => [session.lastTraceId, session]));
  return {
    async ensureBootstrap() {},
    async authenticate() { return { ok: true, actor, session: { id: "uss_realtime_test" } }; },
    async getAgentSession(sessionId) { return bySessionId.get(sessionId) ?? null; },
    async getAgentSessionByTraceId(traceId) { return byTraceId.get(traceId) ?? null; }
  };
}
