Sfoglia il codice sorgente

v2/interview hardening and dedup: lazy interview projection, unified command markers, shared helpers (#1171)

* fix(v2): avoid eager interview transcript projection per context event

* fix(v2): harden interview bridge lazy projection (review nits)

* refactor(v2): unify command marker machinery into one shared kit

* test(v2): pin real command marker kits against config drift

* refactor: extract shared helpers across gates, tools, and interview modules

- assertOrchestrator: export from cancel-task.ts (already toolName-
  parameterized); task-revive.ts drops its hardcoded copy
- getGlobalStore<T>(key, init): new src/utils/global-store.ts
  globalThis+Symbol.for lazy singleton; used by wake-gate and
  user-wait-gate (store keys unchanged)
- setup.ts local isRecord replaced by canonical src/utils/guards isRecord
  (identical predicate); delegation.ts asRecord kept (copy+fallback
  semantics)
- joinTextParts(parts, separator): shared text-part joiner in
  session-submit.ts; textFromContent/textContent keep '' separator,
  textFromBody keeps '\n' — byte-identical outputs, pinned by tests
- createInterviewServerDeps(service, outputFolder, port): shared
  service-delegating deps factory in server.ts; used by session-server,
  dashboard fallback, and v2 interview bridge
- computeInterviewMode(config): pure mode computation in manager.ts;
  used by the v1 manager and the v2 interview bridge (routing stays
  separate)

* docs(v2): cross-reference asRecord vs guards.isRecord semantics

* fix(v2): exempt active interviews from bridge retention eviction

* fix(v2): snapshot interview events before transforms

* chore(v2): remove stale command marker helper

---------

Co-authored-by: Alvin Unreal <alvin@boringdystopia.ai>
Gold John King 3 giorni fa
parent
commit
54231c75bf

+ 5 - 7
src/hooks/orchestrator-wake/wake-gate.ts

@@ -3,6 +3,8 @@
  * in-flight ownership. Shared across independently created hook instances in
  * the same JS process via globalThis + Symbol.for.
  */
+
+import { getGlobalStore } from '../../utils/global-store';
 import type { ContinuationModelSelection } from '../task-session-manager/continuation-model-selection';
 
 export type WakeProgressState = {
@@ -24,20 +26,16 @@ type WakeGateStore = {
   order: string[];
 };
 
-const STORE_KEY = Symbol.for('oh-my-opencode-slim.orchestrator-wake-gate');
+const STORE_KEY = 'oh-my-opencode-slim.orchestrator-wake-gate';
 const MAX_TRACKED_SESSIONS = 256;
 
 function getStore(): WakeGateStore {
-  const globalWithStore = globalThis as typeof globalThis & {
-    [STORE_KEY]?: WakeGateStore;
-  };
-  globalWithStore[STORE_KEY] ??= {
+  return getGlobalStore<WakeGateStore>(STORE_KEY, () => ({
     progress: new Map(),
     inFlight: new Map(),
     releaseWaiters: new Map(),
     order: [],
-  };
-  return globalWithStore[STORE_KEY];
+  }));
 }
 
 function touchOrder(sessionID: string): void {

+ 5 - 7
src/hooks/task-session-manager/user-wait-gate.ts

@@ -1,3 +1,5 @@
+import { getGlobalStore } from '../../utils/global-store';
+
 /**
  * Process-local gate for explicit wait_for_user HITL latches.
  *
@@ -21,18 +23,14 @@ type UserWaitStore = {
   messageObjectIdentity: WeakMap<object, symbol>;
 };
 
-const STORE_KEY = Symbol.for('oh-my-opencode-slim.user-wait-gate');
+const STORE_KEY = 'oh-my-opencode-slim.user-wait-gate';
 
 function getStore(): UserWaitStore {
-  const globalWithStore = globalThis as typeof globalThis & {
-    [STORE_KEY]?: UserWaitStore;
-  };
-  globalWithStore[STORE_KEY] ??= {
+  return getGlobalStore<UserWaitStore>(STORE_KEY, () => ({
     waits: new Map(),
     lastRearmIdentity: new Map(),
     messageObjectIdentity: new WeakMap(),
-  };
-  return globalWithStore[STORE_KEY];
+  }));
 }
 
 /**

+ 5 - 1
src/interview/codemap.md

@@ -25,7 +25,8 @@
   implementation without expanding the global client shim.
 
 - `manager.ts` (composition root)
-  - Chooses mode via `interview.dashboard === true || interview.port > 0`:
+  - Chooses mode via the shared pure `computeInterviewMode` (also used by the
+    v2 interview bridge): `interview.dashboard === true || interview.port > 0`:
     - per-session mode → `createPerSessionInterviewServer` (`session-server.ts`)
     - dashboard mode → `createDashboardManager` (`dashboard-manager.ts`)
   - Returns event hooks:
@@ -68,6 +69,9 @@
 
 - `createInterviewServer` (`server.ts`)
   - Owns the per-session HTTP endpoints; HTML rendering lives in `ui.ts`.
+  - `createInterviewServerDeps(service, outputFolder, port)` builds the
+    service-delegating deps object shared by the per-session server, the
+    dashboard fallback server, and the v2 interview bridge.
   - Supports:
     - `GET /`, `GET /api/interviews`, `GET /interview/{id}`
     - `GET /api/interviews/{id}/state`

+ 4 - 16
src/interview/dashboard-manager.ts

@@ -11,7 +11,7 @@ import {
   tryBecomeDashboard,
 } from './dashboard';
 import type { InterviewSessionRuntime } from './runtime';
-import { createInterviewServer } from './server';
+import { createInterviewServer, createInterviewServerDeps } from './server';
 import { createInterviewService } from './service';
 import type {
   InterviewRecord,
@@ -371,21 +371,9 @@ export function createDashboardManager(
       // service, exactly like the non-dashboard mode would.
       isDashboard = false;
       const resolvedOutputPath = path.join(ctx.directory, outputFolder);
-      fallbackServer = createInterviewServer({
-        getState: async (interviewId) => service.getInterviewState(interviewId),
-        listInterviewFiles: async () => service.listInterviewFiles(),
-        listInterviews: () => service.listInterviews(),
-        submitAnswers: async (interviewId, answers) =>
-          service.submitAnswers(interviewId, answers),
-        submitBlockComment: async (interviewId, section, comment) =>
-          service.submitBlockComment(interviewId, section, comment),
-        submitChat: async (interviewId, message) =>
-          service.submitChat(interviewId, message),
-        handleNudgeAction: async (interviewId, action) =>
-          service.handleNudgeAction(interviewId, action),
-        outputFolder: resolvedOutputPath,
-        port: 0,
-      });
+      fallbackServer = createInterviewServer(
+        createInterviewServerDeps(service, resolvedOutputPath, 0),
+      );
       service.setBaseUrlResolver(
         () =>
           fallbackServer?.ensureStarted() ??

+ 50 - 0
src/interview/interview.test.ts

@@ -2130,3 +2130,53 @@ describe('interview service abandoned-record retention', () => {
     }
   });
 });
+
+describe('interview service empty-transcript belt (v2 retention loss)', () => {
+  test('an active interview with an empty whole-transcript read stays awaiting-agent', async () => {
+    const tempDir = await fs.mkdtemp('/tmp/interview-test-');
+    try {
+      const ctx = createMockContext({ directory: tempDir });
+      const service = createInterviewService(ctx, undefined, {
+        // A runtime whose whole-transcript read is empty — impossible on
+        // v1 (real SDK reads), the exact state a live v2 interview sees
+        // when the bridge's retention eviction dropped its transcript.
+        runtime: {
+          messages: async () => [],
+          notify: async () => {},
+          continue: async () => {},
+          rename: async () => {},
+        },
+      });
+      service.setBaseUrlResolver(async () => 'http://localhost:9999');
+
+      const output = { parts: [] as Array<{ type: string; text?: string }> };
+      await service.handleCommandExecuteBefore(
+        {
+          command: 'interview',
+          sessionID: 'ses_evicted',
+          arguments: 'eviction belt idea',
+        },
+        output,
+      );
+      const interviewId = service.getActiveInterviewId('ses_evicted');
+      expect(interviewId).not.toBeNull();
+
+      // Idle + no parsed state: without the belt this computed 'completed'
+      // even though the whole-transcript read was empty — the answer form
+      // vanished for a live interview (PR #1171).
+      await service.handleEvent({
+        event: {
+          type: 'session.status',
+          properties: {
+            sessionID: 'ses_evicted',
+            status: { type: 'idle' },
+          },
+        },
+      });
+      const state = await service.getInterviewState(interviewId as string);
+      expect(state.mode).toBe('awaiting-agent');
+    } finally {
+      await fs.rm(tempDir, { recursive: true, force: true });
+    }
+  });
+});

+ 63 - 2
src/interview/manager.test.ts

@@ -3,9 +3,12 @@ import * as fs from 'node:fs/promises';
 import type { Server } from 'node:http';
 import { createServer as createNetServer } from 'node:net';
 import type { PluginConfig } from '../config';
-import { readDashboardAuthFile } from './dashboard';
+import { DEFAULT_DASHBOARD_PORT, readDashboardAuthFile } from './dashboard';
 import { createDashboardManager } from './dashboard-manager';
-import { createInterviewManager as createInterviewManagerImpl } from './manager';
+import {
+  computeInterviewMode,
+  createInterviewManager as createInterviewManagerImpl,
+} from './manager';
 import { bindFreePort } from './test-port';
 
 // Intercept getClient so the manager's service uses the same session mocks.
@@ -1352,3 +1355,61 @@ describe('interview manager - dashboard election failure fallback', () => {
     }
   });
 });
+
+describe('computeInterviewMode', () => {
+  test('undefined config resolves to per-session defaults', () => {
+    expect(computeInterviewMode(undefined)).toEqual({
+      dashboardEnabled: false,
+      outputFolder: 'interview',
+      dashboardPort: DEFAULT_DASHBOARD_PORT,
+    });
+  });
+
+  test('dashboard flag enables dashboard mode with the default port', () => {
+    expect(
+      computeInterviewMode({
+        maxQuestions: 2,
+        outputFolder: 'interview',
+        autoOpenBrowser: true,
+        port: 0,
+        dashboard: true,
+      }),
+    ).toEqual({
+      dashboardEnabled: true,
+      outputFolder: 'interview',
+      dashboardPort: DEFAULT_DASHBOARD_PORT,
+    });
+  });
+
+  test('an explicit port enables dashboard mode and wins over the default', () => {
+    expect(
+      computeInterviewMode({
+        maxQuestions: 2,
+        outputFolder: 'interview',
+        autoOpenBrowser: true,
+        port: 4321,
+        dashboard: false,
+      }),
+    ).toEqual({
+      dashboardEnabled: true,
+      outputFolder: 'interview',
+      dashboardPort: 4321,
+    });
+  });
+
+  test('a custom output folder is passed through untouched', () => {
+    expect(
+      computeInterviewMode({
+        maxQuestions: 3,
+        outputFolder: 'specs/interviews',
+        autoOpenBrowser: false,
+        port: 0,
+        dashboard: false,
+      }),
+    ).toEqual({
+      dashboardEnabled: false,
+      outputFolder: 'specs/interviews',
+      dashboardPort: DEFAULT_DASHBOARD_PORT,
+    });
+  });
+});

+ 22 - 8
src/interview/manager.ts

@@ -1,10 +1,29 @@
 import type { Server } from 'node:http';
 import type { PluginInput } from '@opencode-ai/plugin';
-import type { PluginConfig } from '../config';
+import type { InterviewConfig, PluginConfig } from '../config';
 import { DEFAULT_DASHBOARD_PORT } from './dashboard';
 import { createDashboardManager } from './dashboard-manager';
 import { createPerSessionInterviewServer } from './session-server';
 
+/**
+ * Pure interview mode computation shared by the v1 manager and the v2
+ * interview bridge: dashboard enablement (`dashboard === true` or an
+ * explicit port), the resolved output folder, and the dashboard port
+ * (explicit port when set, else the default).
+ */
+export function computeInterviewMode(config: InterviewConfig | undefined): {
+  dashboardEnabled: boolean;
+  outputFolder: string;
+  dashboardPort: number;
+} {
+  const effectivePort = config?.port ?? 0;
+  return {
+    dashboardEnabled: config?.dashboard === true || effectivePort > 0,
+    outputFolder: config?.outputFolder ?? 'interview',
+    dashboardPort: effectivePort > 0 ? effectivePort : DEFAULT_DASHBOARD_PORT,
+  };
+}
+
 export function createInterviewManager(
   ctx: PluginInput,
   config: PluginConfig,
@@ -24,10 +43,8 @@ export function createInterviewManager(
   dispose: () => Promise<void> | void;
 } {
   const interviewConfig = config.interview;
-  const effectivePort = interviewConfig?.port ?? 0;
-  const dashboardEnabled =
-    interviewConfig?.dashboard === true || effectivePort > 0;
-  const outputFolder = interviewConfig?.outputFolder ?? 'interview';
+  const { dashboardEnabled, outputFolder, dashboardPort } =
+    computeInterviewMode(interviewConfig);
 
   // ─── Per-session mode (upstream behavior) ───────────────────────
   if (!dashboardEnabled) {
@@ -35,9 +52,6 @@ export function createInterviewManager(
   }
 
   // ─── Dashboard mode ─────────────────────────────────────────────
-  const dashboardPort =
-    effectivePort > 0 ? effectivePort : DEFAULT_DASHBOARD_PORT;
-
   return createDashboardManager(
     ctx,
     config,

+ 34 - 0
src/interview/server.ts

@@ -6,6 +6,7 @@ import {
 } from 'node:http';
 import { URL } from 'node:url';
 import { extractResumeSlug, readJsonBody, sendHtml, sendJson } from './helpers';
+import type { createInterviewService } from './service';
 import type {
   InterviewAnswer,
   InterviewFileItem,
@@ -70,6 +71,39 @@ function parseAnswersPayload(value: unknown): { answers: InterviewAnswer[] } {
   };
 }
 
+/**
+ * Server deps delegating every service operation to a single
+ * `createInterviewService` instance — the shared shape used by the
+ * per-session server, the dashboard fallback server, and the v2 interview
+ * bridge.
+ */
+export function createInterviewServerDeps(
+  service: ReturnType<typeof createInterviewService>,
+  outputFolder: string,
+  port: number,
+) {
+  return {
+    getState: (interviewId: string) => service.getInterviewState(interviewId),
+    listInterviewFiles: () => service.listInterviewFiles(),
+    listInterviews: () => service.listInterviews(),
+    submitAnswers: (interviewId: string, answers: InterviewAnswer[]) =>
+      service.submitAnswers(interviewId, answers),
+    submitBlockComment: (
+      interviewId: string,
+      section: string,
+      comment: string,
+    ) => service.submitBlockComment(interviewId, section, comment),
+    submitChat: (interviewId: string, message: string) =>
+      service.submitChat(interviewId, message),
+    handleNudgeAction: (
+      interviewId: string,
+      action: 'more-questions' | 'confirm-complete',
+    ) => service.handleNudgeAction(interviewId, action),
+    outputFolder,
+    port,
+  };
+}
+
 export function createInterviewServer(deps: {
   getState: (interviewId: string) => Promise<InterviewState>;
   listInterviewFiles: () => Promise<InterviewFileItem[]>;

+ 8 - 1
src/interview/service.ts

@@ -479,7 +479,14 @@ export function createInterviewService(
                 ? 'awaiting-user'
                 : parsed.latestAssistantError
                   ? 'error'
-                  : !parsed.state &&
+                  : // An empty WHOLE-transcript read (impossible on v1
+                    // runtimes; reachable on v2 only via bridge retention
+                    // loss) must not read as 'completed' — the answer form
+                    // would vanish for a live interview. Keyed on
+                    // allMessages, NOT interviewMessages: an empty
+                    // post-base slice legitimately awaits the first answer.
+                    !parsed.state &&
+                      allMessages.length > 0 &&
                       sessionBusy.get(interview.sessionID) === false
                     ? 'completed'
                     : 'awaiting-agent',

+ 4 - 16
src/interview/session-server.ts

@@ -1,7 +1,7 @@
 import path from 'node:path';
 import type { PluginInput } from '@opencode-ai/plugin';
 import type { InterviewConfig } from '../config';
-import { createInterviewServer } from './server';
+import { createInterviewServer, createInterviewServerDeps } from './server';
 import { createInterviewService } from './service';
 
 export function createPerSessionInterviewServer(
@@ -21,21 +21,9 @@ export function createPerSessionInterviewServer(
 } {
   const service = createInterviewService(ctx, interviewConfig);
   const resolvedOutputPath = path.join(ctx.directory, outputFolder);
-  const server = createInterviewServer({
-    getState: async (interviewId) => service.getInterviewState(interviewId),
-    listInterviewFiles: async () => service.listInterviewFiles(),
-    listInterviews: () => service.listInterviews(),
-    submitAnswers: async (interviewId, answers) =>
-      service.submitAnswers(interviewId, answers),
-    submitBlockComment: async (interviewId, section, comment) =>
-      service.submitBlockComment(interviewId, section, comment),
-    submitChat: async (interviewId, message) =>
-      service.submitChat(interviewId, message),
-    handleNudgeAction: async (interviewId, action) =>
-      service.handleNudgeAction(interviewId, action),
-    outputFolder: resolvedOutputPath,
-    port: 0,
-  });
+  const server = createInterviewServer(
+    createInterviewServerDeps(service, resolvedOutputPath, 0),
+  );
   service.setBaseUrlResolver(() => server.ensureStarted());
   let disposed = false;
 

+ 4 - 1
src/tools/cancel-task.ts

@@ -431,7 +431,10 @@ function assertLease(
   }
 }
 
-function assertOrchestrator(
+/** Shared orchestrator-only guard for task control tools: requires a
+ * sessionID, rejects non-orchestrator agents, and requires the session to
+ * be orchestrator-managed. Returns the validated parent session ID. */
+export function assertOrchestrator(
   options: TaskControlToolOptions,
   toolContext: { sessionID?: string; agent?: string } | undefined,
   toolName: string,

+ 6 - 16
src/tools/task-revive.ts

@@ -3,6 +3,7 @@ import type { RevivedRunTracker } from '../hooks/task-session-manager/revived-ru
 import type { BackgroundJobSupervisor } from '../utils/background-job-supervisor';
 import { getClient } from '../utils/opencode-client';
 import {
+  assertOrchestrator,
   cancelTrackedExecution,
   type TaskControlToolOptions,
 } from './cancel-task';
@@ -28,7 +29,11 @@ export function createTaskReviveTool(
       prompt: z.string().min(1).describe('Prompt for the revived task'),
     },
     async execute(args, toolContext) {
-      const parentSessionID = assertOrchestrator(options, toolContext);
+      const parentSessionID = assertOrchestrator(
+        options,
+        toolContext,
+        'task_revive',
+      );
       const requested = args.task_id.trim();
       const prompt = args.prompt.trim();
       if (!requested) throw new Error('task_revive requires task_id');
@@ -224,21 +229,6 @@ function isReviveableRetainedJob(
   return job.state === 'reconciled' && job.terminalState !== undefined;
 }
 
-function assertOrchestrator(
-  options: TaskReviveToolOptions,
-  toolContext: { sessionID?: string; agent?: string } | undefined,
-): string {
-  const parentSessionID = toolContext?.sessionID;
-  if (!parentSessionID) throw new Error('task_revive requires sessionID');
-  if (toolContext.agent && toolContext.agent !== 'orchestrator') {
-    throw new Error('task_revive can only be used by orchestrator');
-  }
-  if (!options.shouldManageSession(parentSessionID)) {
-    throw new Error('task_revive can only be used in orchestrator sessions');
-  }
-  return parentSessionID;
-}
-
 function getApiError(response: unknown): unknown {
   if (!response || typeof response !== 'object') return undefined;
   const record = response as Record<string, unknown>;

+ 3 - 0
src/utils/codemap.md

@@ -43,6 +43,8 @@ Centralized utilities and shared abstractions used across the oh-my-opencode-sli
 
 - **Type Guards** (`guards.ts`): Simple type checking utilities (`isRecord`) for runtime validation.
 
+- **Global Store** (`global-store.ts`): `getGlobalStore(key, init)` — process-local lazy singleton on `globalThis` via the `Symbol.for` registry; shared store pattern for the orchestrator-wake and user-wait gates.
+
 - **Environment Utilities** (`env.ts`): Environment variable parsing and plugin disable flag checking.
 
 - **Internal Initiator** (`internal-initiator.ts`): Marker system for identifying internally-initiated agent messages to prevent infinite loops.
@@ -151,6 +153,7 @@ re-exported).
 | `env.ts` | Environment variable utilities |
 | `escape-html.ts` | HTML escaping helper |
 | `frontmatter.ts` | Frontmatter parsing for interview documents |
+| `global-store.ts` | Process-local lazy singleton store on `globalThis` (`getGlobalStore`) |
 | `guards.ts` | Type guard utilities |
 | `internal-initiator.ts` | Internal agent message marker system |
 | `logger.ts` | File-based logging with rotation |

+ 49 - 0
src/utils/global-store.test.ts

@@ -0,0 +1,49 @@
+import { describe, expect, test } from 'bun:test';
+import { getGlobalStore } from './global-store';
+
+describe('getGlobalStore', () => {
+  test('returns the same instance per key across calls', () => {
+    const first = getGlobalStore('test.global-store.a', () => ({ n: 1 }));
+    const second = getGlobalStore('test.global-store.a', () => ({ n: 2 }));
+    expect(second).toBe(first);
+    expect(second.n).toBe(1);
+  });
+
+  test('distinct keys get distinct stores', () => {
+    const a = getGlobalStore('test.global-store.b', () => ({ tag: 'b' }));
+    const c = getGlobalStore('test.global-store.c', () => ({ tag: 'c' }));
+    expect(a).not.toBe(c);
+    expect(c.tag).toBe('c');
+  });
+
+  test('init runs only when the store is absent', () => {
+    let inits = 0;
+    const init = () => {
+      inits += 1;
+      return { count: inits };
+    };
+    const first = getGlobalStore('test.global-store.lazy', init);
+    const second = getGlobalStore('test.global-store.lazy', init);
+    expect(inits).toBe(1);
+    expect(second).toBe(first);
+    expect(first.count).toBe(1);
+  });
+
+  test('adopts a store planted under the same Symbol.for key', () => {
+    // Same global symbol registry: a value stored under Symbol.for(key)
+    // must be adopted instead of re-initialized.
+    const key = 'test.global-store.planted';
+    const planted = { planted: true };
+    (globalThis as Record<symbol, unknown>)[Symbol.for(key)] = planted;
+    const got = getGlobalStore(key, () => ({ planted: false }));
+    expect(got).toBe(planted);
+  });
+
+  test('preserves a falsy-but-present store value', () => {
+    // `??=` assigns only on null/undefined, matching the inline pattern
+    // both gates used before extraction.
+    const key = 'test.global-store.empty-string';
+    (globalThis as Record<symbol, unknown>)[Symbol.for(key)] = '';
+    expect(getGlobalStore(key, () => 'init')).toBe('');
+  });
+});

+ 13 - 0
src/utils/global-store.ts

@@ -0,0 +1,13 @@
+/**
+ * Process-local lazy singleton on `globalThis`, keyed through the global
+ * symbol registry (`Symbol.for`) so independently created hook instances
+ * in the same JS process share one store per key.
+ */
+export function getGlobalStore<T>(key: string, init: () => T): T {
+  const storeKey = Symbol.for(key);
+  const globalWithStore = globalThis as typeof globalThis & {
+    [storeKey]?: T;
+  };
+  globalWithStore[storeKey] ??= init();
+  return globalWithStore[storeKey];
+}

+ 3 - 0
src/v2/delegation.ts

@@ -12,6 +12,9 @@ export function toolNameToV1(tool: string): string {
   return tool.toLowerCase() === DELEGATION_TOOL_V2 ? DELEGATION_TOOL_V1 : tool;
 }
 
+/** Shallow-copy record view with an `{}` fallback for non-objects.
+ * Deliberately NOT `isRecord` from `utils/guards` (a pure type guard):
+ * both subagentArgsToV1 copies rely on the copy + fallback semantics. */
 function asRecord(input: unknown): Record<string, unknown> {
   return input && typeof input === 'object'
     ? { ...(input as Record<string, unknown>) }

+ 34 - 0
src/v2/interview-bridge.test.ts

@@ -244,6 +244,40 @@ describe('v2 interview bridge', () => {
     ]);
   });
 
+  test('snapshots transcript before downstream part injection', async () => {
+    const bridge = createV2InterviewBridge(createContext());
+    const event = {
+      sessionID: 'ses_snapshot',
+      agent: 'orchestrator',
+      model: {},
+      system: [],
+      tools: {},
+      messages: [
+        {
+          id: 'answer',
+          role: 'user',
+          content: [{ type: 'text', text: 'the answer' }],
+        },
+      ],
+    };
+
+    await bridge.handleContext(event);
+    event.messages[0].content.push({
+      type: 'text',
+      text: 'injected by downstream transform',
+      synthetic: true,
+      metadata: { source: 'bridge-test' },
+    });
+
+    expect(bridge.getTranscript('ses_snapshot')).toEqual([
+      {
+        info: { role: 'user', id: 'answer' },
+        parts: [{ type: 'text', text: 'the answer' }],
+      },
+    ]);
+    bridge.dispose();
+  });
+
   test('projects text events and removes a deleted session', async () => {
     const bridge = createV2InterviewBridge(createContext());
     await bridge.handleContext({

+ 86 - 0
src/v2/session-submit.test.ts

@@ -0,0 +1,86 @@
+import { describe, expect, test } from 'bun:test';
+import { joinTextParts, textFromContent } from './session-submit';
+
+describe('joinTextParts', () => {
+  test('joins kept text parts with the separator', () => {
+    expect(
+      joinTextParts(
+        [
+          { type: 'text', text: 'a' },
+          { type: 'text', text: 'b' },
+        ],
+        '\n',
+      ),
+    ).toBe('a\nb');
+  });
+
+  test('empty array yields the empty string for any separator', () => {
+    expect(joinTextParts([], '\n')).toBe('');
+    expect(joinTextParts([], '')).toBe('');
+  });
+
+  test('single part is returned without separator bytes', () => {
+    expect(joinTextParts([{ type: 'text', text: 'only' }], '\n')).toBe('only');
+  });
+
+  test('drops non-text parts, nulls, and non-string text parts', () => {
+    expect(
+      joinTextParts(
+        [
+          { type: 'text', text: 'a' },
+          { type: 'image', uri: 'x' },
+          { type: 'text' },
+          { type: 'text', text: 42 },
+          null,
+          'str',
+          7,
+        ],
+        '\n',
+      ),
+    ).toBe('a');
+  });
+
+  test('empty separator collapses non-string text parts invisibly', () => {
+    // Byte-identity contract for the '' separators: dropping a text part
+    // whose text is not a string contributes no bytes, exactly like the
+    // previous keep-as-empty-string implementations.
+    expect(
+      joinTextParts(
+        [
+          { type: 'text', text: 'a' },
+          { type: 'text', text: undefined },
+          { type: 'text', text: 'b' },
+        ],
+        '',
+      ),
+    ).toBe('ab');
+  });
+
+  test('preserves empty-string text parts as separator-separated slots', () => {
+    expect(
+      joinTextParts(
+        [
+          { type: 'text', text: '' },
+          { type: 'text', text: 'x' },
+        ],
+        '\n',
+      ),
+    ).toBe('\nx');
+  });
+});
+
+describe('textFromContent', () => {
+  test('joins v2 content text parts with no separator', () => {
+    expect(
+      textFromContent([
+        { type: 'text', text: 'hello ' },
+        { type: 'text', text: 'world' },
+        { type: 'file', uri: 'f' },
+      ]),
+    ).toBe('hello world');
+  });
+
+  test('empty content yields empty string', () => {
+    expect(textFromContent([])).toBe('');
+  });
+});

+ 24 - 4
src/v2/session-submit.ts

@@ -36,12 +36,32 @@ export function createSessionSubmit(ctx: V2Context): V2CommandSubmit {
   };
 }
 
+/**
+ * Join the text of `type: 'text'` content parts with `separator`.
+ *
+ * Non-text parts and text parts whose `text` is not a string are dropped.
+ * With an empty separator this is byte-identical to keeping such parts as
+ * empty strings — the join only inserts bytes between kept parts.
+ */
+export function joinTextParts(
+  parts: ReadonlyArray<unknown>,
+  separator: string,
+): string {
+  return parts
+    .filter(
+      (part): part is { text: string } =>
+        typeof part === 'object' &&
+        part !== null &&
+        (part as { type?: unknown }).type === 'text' &&
+        typeof (part as { text?: unknown }).text === 'string',
+    )
+    .map((part) => part.text)
+    .join(separator);
+}
+
 /** Join the text parts of a v2 message content array. */
 export function textFromContent(
   content: Array<Record<string, unknown>>,
 ): string {
-  return content
-    .filter((part) => part.type === 'text')
-    .map((part) => (typeof part.text === 'string' ? part.text : ''))
-    .join('');
+  return joinTextParts(content, '');
 }