Browse Source

feat(v2): map v2 server events into v1 handler shapes

GoldJohnKing 2 weeks ago
parent
commit
11f0f94447
4 changed files with 395 additions and 7 deletions
  1. 6 6
      src/v2/client-shim.test.ts
  2. 229 0
      src/v2/event-adapter.test.ts
  3. 151 0
      src/v2/event-adapter.ts
  4. 9 1
      src/v2/setup.ts

+ 6 - 6
src/v2/client-shim.test.ts

@@ -327,12 +327,12 @@ describe('v2 client shim foreground-fallback integration', () => {
       m: 'prompt',
       i: { sessionID: 'ses_1', delivery: 'steer' },
     });
-    expect(
-      (seq[1].i as { text: string }).text,
-    ).toContain('Fix the failing build');
-    expect(
-      (seq[1].i as { text: string }).text,
-    ).toContain('The previous model request failed');
+    expect((seq[1].i as { text: string }).text).toContain(
+      'Fix the failing build',
+    );
+    expect((seq[1].i as { text: string }).text).toContain(
+      'The previous model request failed',
+    );
 
     // Step 3: abort maps to interrupt with continue:false.
     await session.abort({ path: { id: 'ses_1' } });

+ 229 - 0
src/v2/event-adapter.test.ts

@@ -0,0 +1,229 @@
+/**
+ * Tests for the v2 → v1 event mapper.
+ *
+ * The synthesized v1 shapes here are pinned to what the v1 consumers
+ * actually read:
+ * - `session.created` early registration (task-session-manager
+ *   event-router): `properties.info.{id,parentID,agent?}` — plugin
+ *   relevance is gated on `info.parentID` (child sessions only).
+ * - `message.updated` telemetry (cache-monitor parseCompletedAssistantMessage):
+ *   `properties.info.{role:'assistant', sessionID, id, time.completed,
+ *   tokens.input, tokens.cache.read, tokens.cache.write}`.
+ */
+import { describe, expect, test } from 'bun:test';
+import { createCacheMonitorHook } from '../hooks/cache-monitor';
+import { mapV2EventToV1 } from './event-adapter';
+
+function deepFreeze<T>(value: T): T {
+  if (value && typeof value === 'object') {
+    for (const key of Object.keys(value as object)) {
+      deepFreeze((value as Record<string, unknown>)[key]);
+    }
+    Object.freeze(value);
+  }
+  return value;
+}
+
+function v2Usage(overrides: {
+  input: number;
+  read: number;
+  write?: number;
+  timestamp?: number;
+}): Record<string, unknown> {
+  return {
+    type: 'session.usage.updated',
+    properties: deepFreeze({
+      sessionID: 'ses_map',
+      ...(overrides.timestamp !== undefined
+        ? { timestamp: overrides.timestamp }
+        : {}),
+      tokens: {
+        input: overrides.input,
+        output: 5,
+        reasoning: 0,
+        cache: { read: overrides.read, write: overrides.write ?? 0 },
+      },
+    }),
+  };
+}
+
+describe('mapV2EventToV1', () => {
+  test('passes unknown events through unchanged (raw reference first)', () => {
+    const ev = { type: 'permission.asked', properties: { sessionID: 's' } };
+    const out = mapV2EventToV1(ev);
+    expect(out).toHaveLength(1);
+    expect(out[0]).toBe(ev);
+  });
+
+  test('never mutates the input event', () => {
+    const ev = deepFreeze(
+      v2Usage({ input: 10, read: 100, write: 20, timestamp: 1_700_000_000 }),
+    );
+    expect(() => mapV2EventToV1(ev)).not.toThrow();
+  });
+
+  test('synthesizes session.idle from idle session.status', () => {
+    const ev = {
+      type: 'session.status',
+      properties: { sessionID: 's', status: { type: 'idle' } },
+    };
+    const out = mapV2EventToV1(ev);
+    expect(out).toHaveLength(2);
+    expect(out[0]).toBe(ev);
+    expect(out[1]).toEqual({
+      type: 'session.idle',
+      properties: { sessionID: 's' },
+    });
+  });
+
+  test('busy status does not synthesize idle', () => {
+    expect(
+      mapV2EventToV1({
+        type: 'session.status',
+        properties: { sessionID: 's', status: { type: 'busy' } },
+      }),
+    ).toHaveLength(1);
+  });
+
+  test('idle status without a sessionID does not synthesize idle', () => {
+    expect(
+      mapV2EventToV1({
+        type: 'session.status',
+        properties: { status: { type: 'idle' } },
+      }),
+    ).toHaveLength(1);
+  });
+
+  test('maps session.created with parentID into v1 early-registration shape', () => {
+    const ev = {
+      type: 'session.created',
+      properties: { sessionID: 'child_1', parentID: 'parent_1', title: 't' },
+    };
+    const out = mapV2EventToV1(ev);
+    expect(out).toHaveLength(2);
+    expect(out[0]).toBe(ev);
+    // Exact shape event-router reads: info.id + info.parentID gate the
+    // early board registration; info.agent disambiguates parallel task
+    // calls (absent here → omitted).
+    expect(out[1]).toEqual({
+      type: 'session.created',
+      properties: { info: { id: 'child_1', parentID: 'parent_1', title: 't' } },
+    });
+  });
+
+  test('passes agent through on session.created when the host provides it', () => {
+    const out = mapV2EventToV1({
+      type: 'session.created',
+      properties: {
+        sessionID: 'child_1',
+        parentID: 'parent_1',
+        agent: 'fixer',
+      },
+    });
+    expect(out[1]).toEqual({
+      type: 'session.created',
+      properties: {
+        info: { id: 'child_1', parentID: 'parent_1', agent: 'fixer' },
+      },
+    });
+  });
+
+  test('session.created without parentID stays passthrough-only', () => {
+    // Root sessions are not plugin-relevant for early registration —
+    // event-router gates on info.parentID, so no v1 shape is synthesized.
+    expect(
+      mapV2EventToV1({
+        type: 'session.created',
+        properties: { sessionID: 'root_1', title: 't' },
+      }),
+    ).toHaveLength(1);
+  });
+
+  test('maps usage telemetry into v1 message.updated shape for cache-monitor', () => {
+    const out = mapV2EventToV1(v2Usage({ input: 10, read: 100, write: 20 }));
+    expect(out).toHaveLength(2);
+    // Exact field paths parseCompletedAssistantMessage reads. The id is a
+    // deterministic fingerprint (v2 usage events carry no message id) so
+    // replays and the step.ended/usage.updated pair for one request dedup.
+    expect(out[1]).toEqual({
+      type: 'message.updated',
+      properties: {
+        info: {
+          id: 'v2-usage:ses_map:10:5:100:20',
+          role: 'assistant',
+          sessionID: 'ses_map',
+          time: { completed: 0 },
+          tokens: {
+            input: 10,
+            output: 5,
+            reasoning: 0,
+            cache: { read: 100, write: 20 },
+          },
+        },
+      },
+    });
+  });
+
+  test('maps session.step.ended telemetry with timestamp passthrough', () => {
+    const out = mapV2EventToV1({
+      type: 'session.step.ended',
+      properties: {
+        sessionID: 'ses_map',
+        timestamp: 1_700_000_000,
+        tokens: { input: 7, output: 3, cache: { read: 40, write: 5 } },
+      },
+    });
+    expect(out[1]).toEqual({
+      type: 'message.updated',
+      properties: {
+        info: {
+          id: 'v2-usage:ses_map:7:3:40:5',
+          role: 'assistant',
+          sessionID: 'ses_map',
+          time: { completed: 1_700_000_000 },
+          tokens: {
+            input: 7,
+            output: 3,
+            reasoning: 0,
+            cache: { read: 40, write: 5 },
+          },
+        },
+      },
+    });
+  });
+
+  test('usage telemetry with incomplete tokens synthesizes nothing', () => {
+    // Fail-open like the consumers: partial token blocks are dropped
+    // rather than mapped into a shape cache-monitor would half-read.
+    expect(
+      mapV2EventToV1({
+        type: 'session.usage.updated',
+        properties: { sessionID: 's', tokens: { input: 10 } },
+      }),
+    ).toHaveLength(1);
+    expect(
+      mapV2EventToV1({
+        type: 'session.usage.updated',
+        properties: { sessionID: 's' },
+      }),
+    ).toHaveLength(1);
+  });
+
+  test('mapped message.updated feeds the real cache-monitor (bust warning fires)', async () => {
+    const warnings: string[] = [];
+    const monitor = createCacheMonitorHook({
+      logger: (message) => warnings.push(message),
+    });
+    await monitor.event({
+      event: mapV2EventToV1(v2Usage({ input: 8000, read: 0, write: 7000 }))[1],
+    });
+    await monitor.event({
+      event: mapV2EventToV1(v2Usage({ input: 500, read: 9000 }))[1],
+    });
+    await monitor.event({
+      event: mapV2EventToV1(v2Usage({ input: 12000, read: 0 }))[1],
+    });
+    expect(warnings).toHaveLength(1);
+    expect(warnings[0]).toContain('prompt-cache bust');
+  });
+});

+ 151 - 0
src/v2/event-adapter.ts

@@ -0,0 +1,151 @@
+/**
+ * v2 → v1 event mapper for the v2 event pump.
+ *
+ * v2 renamed/re-shaped several server events the v1 hooks depend on:
+ * - `session.idle` is gone (`session.status` with `status.type: 'idle'`);
+ * - `session.created` carries flat `{sessionID, parentID?}` instead of
+ *   v1's `properties.info` object;
+ * - token/cache telemetry moved to `session.usage.updated` /
+ *   `session.step.ended` (v2 has no `message.updated`).
+ *
+ * `mapV2EventToV1` is additive synthesis only: the first element of the
+ * returned array is ALWAYS the raw input event, unmodified (byte-identical
+ * reference), so v2-native handlers (interview bridge) and any v1 handler
+ * already tolerant of the v2 shape keep seeing it. Synthesized v1-shape
+ * events are appended after it.
+ *
+ * The synthesized shapes are pinned to what the v1 consumers actually read:
+ * - `session.created` early registration (task-session-manager
+ *   event-router): `properties.info.{id,parentID,agent?}` — plugin
+ *   relevance is gated on `info.parentID` (child sessions only).
+ * - `message.updated` telemetry (cache-monitor
+ *   parseCompletedAssistantMessage): `properties.info.{role:'assistant',
+ *   sessionID, id, time.completed, tokens.input, tokens.cache.read,
+ *   tokens.cache.write}`.
+ */
+
+import { isRecord } from '../utils/guards';
+
+function finiteNumber(value: unknown): number | undefined {
+  return typeof value === 'number' && Number.isFinite(value)
+    ? value
+    : undefined;
+}
+
+/**
+ * v2 usage telemetry (`session.usage.updated` / `session.step.ended`)
+ * → v1 completed-assistant `message.updated`.
+ *
+ * The documented v2 event carries no message identity, so `info.id` is a
+ * deterministic fingerprint of the telemetry content: the
+ * step.ended/usage.updated pair for one request dedups to a single
+ * observation (cache-monitor dedups by message id), replays are stable,
+ * and genuinely distinct token snapshots stay distinct. No wall-clock or
+ * randomness — only fields already on the event.
+ */
+function usageToMessageUpdated(
+  props: Record<string, unknown>,
+): Record<string, unknown> | undefined {
+  const sessionID = props.sessionID;
+  if (typeof sessionID !== 'string') return undefined;
+  const tokens = isRecord(props.tokens) ? props.tokens : undefined;
+  if (!tokens) return undefined;
+  const cache = isRecord(tokens.cache) ? tokens.cache : undefined;
+  const input = finiteNumber(tokens.input);
+  const cacheRead = finiteNumber(cache?.read);
+  const cacheWrite = finiteNumber(cache?.write);
+  // Fail-open like the consumer: incomplete token blocks are dropped,
+  // never mapped into a half-readable shape.
+  if (
+    input === undefined ||
+    cacheRead === undefined ||
+    cacheWrite === undefined
+  ) {
+    return undefined;
+  }
+  const output = finiteNumber(tokens.output) ?? 0;
+  const reasoning = finiteNumber(tokens.reasoning) ?? 0;
+  const id = `v2-usage:${sessionID}:${input}:${output}:${cacheRead}:${cacheWrite}`;
+  // cache-monitor only requires a non-null completed marker; prefer a
+  // real timestamp from the event when the host provides one.
+  const completedAt =
+    finiteNumber(props.timestamp) ?? finiteNumber(props.activityAt) ?? 0;
+  return {
+    type: 'message.updated',
+    properties: {
+      info: {
+        id,
+        role: 'assistant',
+        sessionID,
+        time: { completed: completedAt },
+        tokens: {
+          input,
+          output,
+          reasoning,
+          cache: { read: cacheRead, write: cacheWrite },
+        },
+      },
+    },
+  };
+}
+
+/**
+ * Map one v2 server event into zero or more v1-shape events.
+ *
+ * Returns `[rawEvent, ...synthesizedV1Shapes]` — the raw event is always
+ * first and never mutated. Synthesis:
+ * - idle `session.status` → v1 `session.idle` `{sessionID}`;
+ * - child `session.created` (parentID present) → v1 early-registration
+ *   shape `{info: {id, parentID, title?, agent?}}`;
+ * - usage telemetry → v1 completed-assistant `message.updated`.
+ *
+ * `interviewBridge.handleEvent` keeps receiving the RAW v2 event (the
+ * setup pump dispatches it before iterating this array).
+ */
+export function mapV2EventToV1(
+  event: Record<string, unknown>,
+): Array<Record<string, unknown>> {
+  const out: Array<Record<string, unknown>> = [event];
+  const type = typeof event.type === 'string' ? event.type : '';
+  const props = isRecord(event.properties) ? event.properties : {};
+
+  if (type === 'session.status') {
+    const statusType = isRecord(props.status)
+      ? typeof props.status.type === 'string'
+        ? props.status.type
+        : undefined
+      : undefined;
+    if (statusType === 'idle' && typeof props.sessionID === 'string') {
+      out.push({
+        type: 'session.idle',
+        properties: { sessionID: props.sessionID },
+      });
+    }
+  } else if (type === 'session.created') {
+    // Only child sessions are plugin-relevant: event-router gates early
+    // board registration on `info.parentID` + shouldManageSession(parent).
+    // Root sessions pass through untouched — no invented fields.
+    if (
+      typeof props.sessionID === 'string' &&
+      typeof props.parentID === 'string'
+    ) {
+      const info: Record<string, unknown> = {
+        id: props.sessionID,
+        parentID: props.parentID,
+      };
+      if (typeof props.title === 'string') info.title = props.title;
+      // event-router matches parallel task calls by child agent; pass the
+      // host-provided value through when present, never fabricate one.
+      if (typeof props.agent === 'string') info.agent = props.agent;
+      out.push({ type: 'session.created', properties: { info } });
+    }
+  } else if (
+    type === 'session.usage.updated' ||
+    type === 'session.step.ended'
+  ) {
+    const mapped = usageToMessageUpdated(props);
+    if (mapped) out.push(mapped);
+  }
+
+  return out;
+}

+ 9 - 1
src/v2/setup.ts

@@ -17,6 +17,7 @@ import { initLogger, log } from '../utils/logger';
 import { adaptTool, applyAgentToDraft } from './adapters';
 import { buildPluginInput, resolveV2Directory } from './client-shim';
 import { subagentArgsToV1, toolNameToV1, v1ArgsToSubagent } from './delegation';
+import { mapV2EventToV1 } from './event-adapter';
 import { createV2InterviewBridge } from './interview-bridge';
 import {
   createSessionSubmit,
@@ -705,8 +706,15 @@ export function createV2Setup(): (ctx: V2Context) => Promise<V2Cleanup> {
               const next = await eventIterator.next();
               if (next.done) break;
               try {
+                // interviewBridge keeps the RAW v2 event; the v1 eventHook
+                // loop iterates raw + synthesized v1 shapes (idle,
+                // early-registration created, message.updated telemetry).
                 await interviewBridge.handleEvent(next.value);
-                if (eventHook) await eventHook({ event: next.value });
+                if (eventHook) {
+                  for (const ev of mapV2EventToV1(next.value)) {
+                    await eventHook({ event: ev });
+                  }
+                }
               } catch (err) {
                 log('[v2] event handler failed', String(err));
               }