Просмотр исходного кода

fix(v2): address review findings in prompt bridge and cache-hint scope

P1 (first prompt state dropped): the prompt hook forwarded the first
admitted prompt before any context event had supplied the agent, so
both forwards (parts without agent, then agent without parts) were
dropped by the shouldManageSession gates — the first external message
could fail to clear a restored input-wait latch or rearm orchestrator-
wake progress. The bridge now latches the first admission per session
and flushes it as one delivery (parts + agent + model + messageID) on
the first agent-bearing context event, with bounded fallbacks (next
admission, or a context event whose user message moved past the
pending prompt). Red/green regression test added.

P2 (cache hint scope interleave): the synthetic-part cache-hint default
was a module global held across the awaited messages transform, and v2
hosts run context callbacks for different sessions concurrently
(per-key serialization only), so overlapping callbacks could interleave
set/restore and drop the hint. The scoped default now rides
AsyncLocalStorage (runWithSyntheticPartCacheHintScope wraps each
bridged transform); helper signatures unchanged; v1 never enters a
scope, so v1 payload bytes are identical. Two-session interleaving
regression test added (red on the pre-fix source).

Gate: typecheck + check:ci clean; 2581 tests pass; cache-safety
suites green with zero snapshot updates.
GoldJohnKing 1 неделя назад
Родитель
Сommit
cc7c3813bc

+ 20 - 6
docs/opencode-v2-compatibility.md

@@ -115,15 +115,26 @@ degrades that single feature with a log line instead of breaking the load.
       `cache-safe-injection` carry a v2 `ContentPart.cache`
       `{type: "ephemeral"}` hint (CacheHint tagging) so providers that
       honor manual breakpoints cap the injected zone's cache contribution;
-      the hint is scoped to the v2 bridge, so v1 payload bytes never
-      change.
+      the hint is scoped per request (an `AsyncLocalStorage` scope around
+      the bridged transform) so concurrent sessions' transforms cannot
+      interleave their set/restore, and the v1 pipeline never enters the
+      scope, so v1 payload bytes never change.
     - a native `ctx.session.hook("prompt")` registration (capability-
       guarded): the v2 prompt hook fires **once per admitted input** with
       the eventual inbox User `messageID`, giving the v1 `chat.message`
       consumers (task-session-manager / orchestrator-wake
       `observeChatMessage`, `toolLoopGuard.observeNewUserMessage`) true
-      once-per-admission fidelity with prompt parts. When it registers,
-      the context hook's per-request `chat.message` emulation narrows to
+      once-per-admission fidelity with prompt parts. The FIRST admitted
+      prompt per session is deferred until the first agent-bearing
+      context event arrives, then delivered once with parts + agent
+      together — the v1 `chat.message` handler only registers the session
+      agent when a delivery carries one, and its consumers gate on that
+      registration, so an agent-less first forward would be dropped (lost
+      input-wait latch clearing / wake-progress rearm). Bounded fallbacks
+      (next admission, or a context event whose trailing user message has
+      moved past the pending one) flush a still-pending prompt best-known
+      when no agent is ever learned. When the prompt hook registers, the
+      context hook's per-request `chat.message` emulation narrows to
       agent/model discovery; hosts that reject the hook name keep the
       full emulation as fallback.
     - `tool.execute.before/after` → `ctx.tool.hook` via
@@ -415,5 +426,8 @@ respawn, bounded by the same no-progress cap as v1.
   addition is CacheHint tagging: parts injected through
   `cache-safe-injection` while the v2 context bridge runs carry
   `cache: {type: "ephemeral"}` (v2 `ContentPart.cache`). The hint is
-  applied via a scoped default inside the v2 bridge only — v1 callers
-  never set it, so the v1 payload (and its snapshots) stay byte-identical.
+  applied via a per-request scoped default (AsyncLocalStorage — the v2
+  host serves different sessions' requests concurrently, so the scope
+  must be isolated per bridged transform) inside the v2 bridge only — v1
+  callers never set it, so the v1 payload (and its snapshots) stay
+  byte-identical.

+ 57 - 0
src/hooks/cache-safe-injection.test.ts

@@ -6,6 +6,7 @@ import {
   hasTaggedPart,
   isTaggedPart,
   isVolatileTaggedMessage,
+  runWithSyntheticPartCacheHintScope,
   setDefaultSyntheticPartCacheHint,
   stripTaggedContent,
 } from './cache-safe-injection';
@@ -101,6 +102,62 @@ describe('createTaggedSyntheticPart', () => {
     expect(part.cache).not.toBe(hint);
     expect(part.cache).toEqual(hint);
   });
+
+  test('interleaved scopes keep independent defaults across awaited transforms', async () => {
+    // Greptile P2 scenario: two concurrent bridged transforms interleave
+    // their set/restore across an await. Scope A restores while scope B is
+    // still running; parts created by B afterwards must still carry B's
+    // own default (a plain module global would have been cleared by A's
+    // restore).
+    let releaseA!: () => void;
+    const gateA = new Promise<void>((resolve) => {
+      releaseA = resolve;
+    });
+    const scopeA = runWithSyntheticPartCacheHintScope(async () => {
+      const restoreA = setDefaultSyntheticPartCacheHint({
+        type: 'ephemeral',
+      });
+      await gateA; // B enters and sets its own default while A waits
+      restoreA();
+    });
+    // Let A run to its await point (set done, parked on gateA).
+    await new Promise<void>((resolve) => setTimeout(resolve, 0));
+    const partB = await runWithSyntheticPartCacheHintScope(async () => {
+      const restoreB = setDefaultSyntheticPartCacheHint({
+        type: 'persistent',
+      });
+      releaseA(); // A finishes and restores while B is still running
+      await scopeA;
+      const part = createTaggedSyntheticPart({
+        text: 'created after A restored',
+        metadataKey: KEY,
+      });
+      restoreB();
+      return part;
+    });
+    expect(partB.cache).toEqual({ type: 'persistent' });
+
+    // After both scopes end, no default leaks (v1 bytes unchanged).
+    const outside = createTaggedSyntheticPart({
+      text: 'outside',
+      metadataKey: KEY,
+    });
+    expect('cache' in outside).toBe(false);
+  });
+
+  test('parts created outside any scope never see a scoped default', async () => {
+    await runWithSyntheticPartCacheHintScope(async () => {
+      const restore = setDefaultSyntheticPartCacheHint({
+        type: 'ephemeral',
+      });
+      restore();
+    });
+    const outside = createTaggedSyntheticPart({
+      text: 'outside',
+      metadataKey: KEY,
+    });
+    expect('cache' in outside).toBe(false);
+  });
 });
 
 describe('isTaggedPart / hasTaggedPart', () => {

+ 46 - 11
src/hooks/cache-safe-injection.ts

@@ -24,6 +24,7 @@
  * See docs/cache-verification.md.
  */
 
+import { AsyncLocalStorage } from 'node:async_hooks';
 import { isRecord } from '../utils/guards';
 import {
   isMessageWithParts,
@@ -55,19 +56,44 @@ export interface TaggedSyntheticPartSpec {
   /**
    * Optional cache hint copied onto the created part. v1 callers never
    * pass it, so the v1 payload stays byte-identical; the v2 context
-   * bridge scopes a process default via `setDefaultSyntheticPartCacheHint`
-   * so every part injected on v2 carries it.
+   * bridge scopes a per-request default via
+   * `runWithSyntheticPartCacheHintScope` +
+   * `setDefaultSyntheticPartCacheHint` so every part injected on v2
+   * carries it, with concurrent transforms isolated.
    */
   cache?: SyntheticPartCacheHint;
 }
 
 /**
- * Current scoped default applied to parts whose spec omits `cache`.
- * ONLY the v2 context bridge may set it (set → run bridged transform →
- * restore); the v1 pipeline never executes inside that wrapper, so v1
- * bytes never change.
+ * Request-scoped default applied to parts whose spec omits `cache`.
+ *
+ * The v2 context bridge runs each bridged messages transform inside its own
+ * scope (`runWithSyntheticPartCacheHintScope`) because the v2 host serves
+ * different sessions' requests concurrently (per-session serialization
+ * only). A plain module global would let one session's restore clobber the
+ * default another session's in-flight transform still depends on — the
+ * AsyncLocalStorage store keeps concurrent set/restore pairs isolated.
+ *
+ * Outside a scope, a module-level fallback keeps the legacy set/restore
+ * helper working. The v1 pipeline never enters a scope and never sets a
+ * default, so v1 bytes never change.
+ */
+const hintScope = new AsyncLocalStorage<{
+  hint?: SyntheticPartCacheHint;
+}>();
+
+/** Legacy fallback for `setDefaultSyntheticPartCacheHint` calls made outside
+ * a `runWithSyntheticPartCacheHintScope` (the v1 pipeline never makes any). */
+let unscopedDefaultCacheHint: SyntheticPartCacheHint | undefined;
+
+/**
+ * Run `fn` with an isolated cache-hint scope: `setDefaultSyntheticPartCacheHint`
+ * calls inside `fn` (and its async descendants) mutate only this scope, so
+ * concurrent callers cannot interleave their set/restore operations.
  */
-let currentDefaultCacheHint: SyntheticPartCacheHint | undefined;
+export function runWithSyntheticPartCacheHintScope<T>(fn: () => T): T {
+  return hintScope.run({}, fn);
+}
 
 /**
  * Set the scoped default cache hint for parts created while the returned
@@ -77,10 +103,18 @@ let currentDefaultCacheHint: SyntheticPartCacheHint | undefined;
 export function setDefaultSyntheticPartCacheHint(
   hint: SyntheticPartCacheHint | undefined,
 ): () => void {
-  const previous = currentDefaultCacheHint;
-  currentDefaultCacheHint = hint;
+  const store = hintScope.getStore();
+  if (store) {
+    const previous = store.hint;
+    store.hint = hint;
+    return () => {
+      store.hint = previous;
+    };
+  }
+  const previous = unscopedDefaultCacheHint;
+  unscopedDefaultCacheHint = hint;
   return () => {
-    currentDefaultCacheHint = previous;
+    unscopedDefaultCacheHint = previous;
   };
 }
 
@@ -88,7 +122,8 @@ export function setDefaultSyntheticPartCacheHint(
 export function createTaggedSyntheticPart(
   spec: TaggedSyntheticPartSpec,
 ): MessagePart {
-  const cache = spec.cache ?? currentDefaultCacheHint;
+  const cache =
+    spec.cache ?? hintScope.getStore()?.hint ?? unscopedDefaultCacheHint;
   return {
     type: 'text',
     synthetic: true,

+ 273 - 4
src/v2/setup-command.test.ts

@@ -1125,11 +1125,21 @@ describe('createSessionPromptBridge (native session.prompt hook)', () => {
     const bridge = createSessionPromptBridge(async (input) => {
       calls.push(input as Record<string, unknown>);
     });
+    // Pre-learn the agent so the admission delivers immediately (the
+    // first-admission deferral is covered by its own tests below).
+    await bridge.observeContext(
+      makeEvent(
+        [{ id: 'msg_0', role: 'user', content: [{ type: 'text', text: 'x' }] }],
+        { sessionID: 'ses_p', agent: 'orchestrator' },
+      ),
+    );
+    calls.length = 0;
     await bridge.handlePrompt(makePromptEvent());
     expect(calls).toEqual([
       {
         sessionID: 'ses_p',
         messageID: 'msg_1',
+        agent: 'orchestrator',
         parts: [{ type: 'text', text: 'do the thing' }],
       },
     ]);
@@ -1148,8 +1158,16 @@ describe('createSessionPromptBridge (native session.prompt hook)', () => {
     const bridge = createSessionPromptBridge(async (input) => {
       calls.push(input as Record<string, unknown>);
     });
+    await bridge.observeContext(
+      makeEvent(
+        [{ id: 'msg_0', role: 'user', content: [{ type: 'text', text: 'x' }] }],
+        { sessionID: 'ses_p', agent: 'orchestrator' },
+      ),
+    );
+    calls.length = 0;
     await bridge.handlePrompt(
       makePromptEvent({
+        messageID: 'msg_f',
         prompt: {
           text: '',
           files: [{ uri: 'file:///a.txt', name: 'a.txt' }],
@@ -1246,7 +1264,8 @@ describe('createSessionPromptBridge (native session.prompt hook)', () => {
   test('handlePrompt feeds the real observeChatMessage consumers', async () => {
     // The v1 observeChatMessage gate that never passed via the context
     // emulation (no parts) must pass via the prompt hook: a non-synthetic
-    // text part + messageID present.
+    // text part + messageID present. Agent is pre-learned so the delivery
+    // is immediate (deferral is covered below).
     const observed: Array<{ sessionID: string; messageID?: string }> = [];
     const bridge = createSessionPromptBridge((input) => {
       observed.push({
@@ -1255,17 +1274,185 @@ describe('createSessionPromptBridge (native session.prompt hook)', () => {
       });
       return Promise.resolve();
     });
+    await bridge.observeContext(
+      makeEvent(
+        [{ id: 'msg_0', role: 'user', content: [{ type: 'text', text: 'x' }] }],
+        { sessionID: 'ses_p', agent: 'orchestrator' },
+      ),
+    );
+    observed.length = 0;
     await bridge.handlePrompt(makePromptEvent());
     expect(observed).toEqual([{ sessionID: 'ses_p', messageID: 'msg_1' }]);
   });
 
+  test('first prompt is deferred until the context event learns the agent (parts + agent together)', async () => {
+    // P1 regression: forwarding the first admitted prompt before the
+    // agent is learned gets it dropped by every v1 consumer (no
+    // sessionMetadata.setAgent → shouldManageSession false), and the
+    // agent-only context forward is dropped by the parts gate. The
+    // bridge must latch the first prompt and flush it when the first
+    // agent-bearing context event arrives.
+    const calls: Array<Record<string, unknown>> = [];
+    const bridge = createSessionPromptBridge(async (input) => {
+      calls.push(input as Record<string, unknown>);
+    });
+    await bridge.handlePrompt(makePromptEvent());
+    expect(calls).toEqual([]); // deferred, not dropped-then-duplicated
+
+    await bridge.observeContext(
+      makeEvent(
+        [
+          {
+            id: 'msg_1',
+            role: 'user',
+            content: [{ type: 'text', text: 'hi' }],
+          },
+        ],
+        {
+          sessionID: 'ses_p',
+          agent: 'orchestrator',
+          model: { id: 'claude-x', providerID: 'anthropic' },
+        },
+      ),
+    );
+    // Exactly ONE delivery, carrying parts + agent + model + messageID
+    // together (the no-parts state forward is superseded by the flush).
+    expect(calls).toEqual([
+      {
+        sessionID: 'ses_p',
+        messageID: 'msg_1',
+        agent: 'orchestrator',
+        model: { providerID: 'anthropic', modelID: 'claude-x' },
+        parts: [{ type: 'text', text: 'do the thing' }],
+      },
+    ]);
+
+    // Re-fired admission + repeated context events: still once.
+    await bridge.handlePrompt(makePromptEvent());
+    await bridge.observeContext(
+      makeEvent(
+        [
+          {
+            id: 'msg_1',
+            role: 'user',
+            content: [{ type: 'text', text: 'hi' }],
+          },
+        ],
+        {
+          sessionID: 'ses_p',
+          agent: 'orchestrator',
+          model: { id: 'claude-x', providerID: 'anthropic' },
+        },
+      ),
+    );
+    expect(calls).toHaveLength(1);
+  });
+
+  test('deferred flush satisfies the v1 setAgent-before-consumers ordering', async () => {
+    // Replica of the real v1 chat.message handler ordering: an agent on
+    // the delivery registers the session agent BEFORE the consumers gate
+    // on it. The deferred flush must pass the consumers' gate where the
+    // old immediate forward (agent-less) and the old context forward
+    // (parts-less) both failed.
+    const sessionAgents = new Map<string, string>();
+    const consumerObserved: Array<string | undefined> = [];
+    const bridge = createSessionPromptBridge(async (input) => {
+      if (input.agent) sessionAgents.set(input.sessionID, input.agent);
+      const isOrchestrator =
+        sessionAgents.get(input.sessionID) === 'orchestrator';
+      const hasExternalPart = Array.isArray(input.parts)
+        ? input.parts.some(
+            (part) =>
+              part &&
+              typeof part === 'object' &&
+              part.type === 'text' &&
+              part.synthetic !== true,
+          )
+        : false;
+      if (isOrchestrator && hasExternalPart) {
+        consumerObserved.push(input.messageID);
+      }
+    });
+    await bridge.handlePrompt(makePromptEvent());
+    await bridge.observeContext(
+      makeEvent(
+        [
+          {
+            id: 'msg_1',
+            role: 'user',
+            content: [{ type: 'text', text: 'hi' }],
+          },
+        ],
+        { sessionID: 'ses_p', agent: 'orchestrator' },
+      ),
+    );
+    expect(consumerObserved).toEqual(['msg_1']);
+  });
+
+  test('fallback: next admission flushes a still-pending prompt best-known', async () => {
+    const calls: Array<Record<string, unknown>> = [];
+    const bridge = createSessionPromptBridge(async (input) => {
+      calls.push(input as Record<string, unknown>);
+    });
+    await bridge.handlePrompt(makePromptEvent()); // pending (no agent yet)
+    await bridge.handlePrompt(makePromptEvent({ messageID: 'msg_2' }));
+    // The pending first admission flushed best-known (no agent learned),
+    // the new one latched — order preserved.
+    expect(calls).toEqual([
+      {
+        sessionID: 'ses_p',
+        messageID: 'msg_1',
+        parts: [{ type: 'text', text: 'do the thing' }],
+      },
+    ]);
+    await bridge.observeContext(
+      makeEvent(
+        [{ id: 'msg_2', role: 'user', content: [{ type: 'text', text: 'x' }] }],
+        { sessionID: 'ses_p', agent: 'orchestrator' },
+      ),
+    );
+    expect(calls).toHaveLength(2);
+    expect(calls[1]).toMatchObject({
+      sessionID: 'ses_p',
+      messageID: 'msg_2',
+      agent: 'orchestrator',
+      parts: [{ type: 'text', text: 'do the thing' }],
+    });
+  });
+
+  test('fallback: a context event past the pending admission flushes best-known', async () => {
+    const calls: Array<Record<string, unknown>> = [];
+    const bridge = createSessionPromptBridge(async (input) => {
+      calls.push(input as Record<string, unknown>);
+    });
+    await bridge.handlePrompt(makePromptEvent()); // pending msg_1
+    // Synthetic/compaction request for the same session: agent absent,
+    // trailing user message already past the pending admission.
+    await bridge.observeContext(
+      makeEvent(
+        [{ id: 'msg_9', role: 'user', content: [{ type: 'text', text: 's' }] }],
+        { sessionID: 'ses_p', agent: undefined as unknown as string },
+      ),
+    );
+    expect(calls).toEqual([
+      {
+        sessionID: 'ses_p',
+        messageID: 'msg_1',
+        parts: [{ type: 'text', text: 'do the thing' }],
+      },
+      // The no-agent state forward itself (trailing id of the new turn).
+      { sessionID: 'ses_p', messageID: 'msg_9' },
+    ]);
+  });
+
   test('handlePrompt restores the internal-initiator marker from prompt metadata (wake admissions stay internal)', async () => {
     // The v2 orchestrator-wake queue prompt arrives with the marker as
     // prompt metadata (part metadata cannot survive the text-only v2
     // translation). The rebuilt parts view must carry the v1 part marker
     // so isInternalInitiatorPart consumers — orchestrator-wake's
     // observeChatMessage in particular — treat the admission as internal
-    // (no no-progress rearm, no timer clear).
+    // (no no-progress rearm, no timer clear). The marker must survive
+    // the deferred first-admission flush too.
     const calls: Array<Record<string, unknown>> = [];
     const bridge = createSessionPromptBridge(async (input) => {
       calls.push(input as Record<string, unknown>);
@@ -1276,6 +1463,13 @@ describe('createSessionPromptBridge (native session.prompt hook)', () => {
         metadata: { 'oh-my-opencode-slim.internalInitiator': true },
       }),
     );
+    expect(calls).toEqual([]); // deferred
+    await bridge.observeContext(
+      makeEvent(
+        [{ id: 'msg_1', role: 'user', content: [{ type: 'text', text: 'w' }] }],
+        { sessionID: 'ses_p', agent: 'orchestrator' },
+      ),
+    );
     expect(calls[0]?.parts).toEqual([
       {
         type: 'text',
@@ -1284,6 +1478,7 @@ describe('createSessionPromptBridge (native session.prompt hook)', () => {
         metadata: { 'oh-my-opencode-slim.internalInitiator': true },
       },
     ]);
+    expect(calls[0]?.agent).toBe('orchestrator');
     // The restored marker must satisfy the real v1 gate.
     const { isInternalInitiatorPart } = await import(
       '../utils/internal-initiator'
@@ -1296,10 +1491,18 @@ describe('createSessionPromptBridge (native session.prompt hook)', () => {
     const bridge2 = createSessionPromptBridge(async (input) => {
       plainCalls.push(input as Record<string, unknown>);
     });
+    await bridge2.observeContext(
+      makeEvent(
+        [{ id: 'msg_1', role: 'user', content: [{ type: 'text', text: 'x' }] }],
+        { sessionID: 'ses_p', agent: 'orchestrator' },
+      ),
+    );
     await bridge2.handlePrompt(
-      makePromptEvent({ prompt: { text: 'user text' } }),
+      makePromptEvent({ messageID: 'msg_2', prompt: { text: 'user text' } }),
     );
-    expect(plainCalls[0]?.parts).toEqual([{ type: 'text', text: 'user text' }]);
+    expect(plainCalls.at(-1)?.parts).toEqual([
+      { type: 'text', text: 'user text' },
+    ]);
   });
 
   test('malformed prompt events are ignored without throwing', async () => {
@@ -1429,4 +1632,70 @@ describe('context handler: native prompt mode + CacheHint', () => {
     const outside = { ...probe.at(-1) } as Record<string, unknown>;
     expect(outside.cache).toBeUndefined();
   });
+
+  test('interleaved transforms for two sessions keep their cache-hint scopes (P2 race)', async () => {
+    // Greptile P2 scenario: the v2 host serves different sessions'
+    // requests concurrently (per-session serialization only), so two
+    // context-hook invocations can overlap across the awaited messages
+    // transform. Session A's restore must not clear the scoped default
+    // session B's still-running transform injects with.
+    const tick = () => new Promise<void>((resolve) => setTimeout(resolve, 0));
+    const gates: Record<string, Array<() => void>> = {
+      ses_a: [],
+      ses_b: [],
+    };
+    const makeGatedTransform = (session: string) => {
+      return async (
+        _input: unknown,
+        output: {
+          messages: Array<{ info: { role: string }; parts: unknown[] }>;
+        },
+      ) => {
+        const target = output.messages.at(-1);
+        if (!target) throw new Error('no message');
+        await new Promise<void>((resolve) => {
+          gates[session].push(resolve);
+        });
+        appendTaggedSyntheticPart(target, {
+          text: `INJECTED ${session}`,
+          metadataKey: 'omos_test_tag',
+        });
+      };
+    };
+    const makeSessionEvent = (session: string) =>
+      makeEvent([{ id: 'u', role: 'user', content: [] }], {
+        sessionID: session,
+      });
+    const makeHandler = (session: string) =>
+      createSessionContextHandler({
+        interviewHandleContext: async () => {},
+        messagesTransform: makeGatedTransform(session),
+        syntheticPartCacheHint: { type: 'ephemeral' },
+      });
+
+    const eventA = makeSessionEvent('ses_a');
+    const eventB = makeSessionEvent('ses_b');
+    const pendingA = makeHandler('ses_a')(eventA);
+    await tick(); // A reaches its transform and parks on its gate
+    const pendingB = makeHandler('ses_b')(eventB);
+    await tick(); // B enters its transform scope and parks (A still set)
+
+    // Release A: it injects and restores its hint scope while B is parked.
+    for (const resolve of gates.ses_a ?? []) resolve();
+    await pendingA;
+    // Only now release B: its part must still carry the v2 cache hint.
+    for (const resolve of gates.ses_b ?? []) resolve();
+    await pendingB;
+
+    const injectedA = eventA.messages[0]?.content.at(-1) as Record<
+      string,
+      unknown
+    >;
+    const injectedB = eventB.messages[0]?.content.at(-1) as Record<
+      string,
+      unknown
+    >;
+    expect(injectedA.cache).toEqual({ type: 'ephemeral' });
+    expect(injectedB.cache).toEqual({ type: 'ephemeral' });
+  });
 });

+ 128 - 59
src/v2/setup.ts

@@ -14,6 +14,7 @@
 import { loadPluginConfig } from '../config/loader';
 import { InterviewConfigSchema } from '../config/schema';
 import {
+  runWithSyntheticPartCacheHintScope,
   type SyntheticPartCacheHint,
   setDefaultSyntheticPartCacheHint,
 } from '../hooks/cache-safe-injection';
@@ -333,28 +334,35 @@ export function createSessionContextHandler(
       // CacheHint tagging (v2-only): parts injected through
       // cache-safe-injection while the bridged transform runs carry an
       // ephemeral cache hint (v2 ContentPart.cache), so providers cap the
-      // injected zone's cache contribution. Scoped set/restore — the v1
-      // pipeline never executes inside this wrapper, so v1 payload bytes
-      // never change (pinned by the v1 snapshot/property suites).
-      const restoreCacheHint = deps.syntheticPartCacheHint
-        ? setDefaultSyntheticPartCacheHint(deps.syntheticPartCacheHint)
-        : undefined;
-      try {
-        const v1messages = event.messages.map((m) => ({
-          info: m,
-          parts: m.content,
-        }));
-        await deps.messagesTransform({}, { messages: v1messages });
-        event.messages = v1messages.map((m) => {
-          const info = m.info as { content?: unknown };
-          info.content = m.parts;
-          return m.info;
-        }) as V2SessionContextEvent['messages'];
-      } catch (err) {
-        log('[v2] messages transform bridge failed', String(err));
-      } finally {
-        restoreCacheHint?.();
-      }
+      // injected zone's cache contribution. Scoped set/restore inside an
+      // isolated AsyncLocalStorage hint scope — the v2 host serves
+      // different sessions' requests concurrently, so a shared module
+      // default could be restored by one session's transform while
+      // another's is still injecting. The v1 pipeline never executes
+      // inside this wrapper, so v1 payload bytes never change (pinned by
+      // the v1 snapshot/property suites).
+      const messagesTransform = deps.messagesTransform;
+      await runWithSyntheticPartCacheHintScope(async () => {
+        const restoreCacheHint = deps.syntheticPartCacheHint
+          ? setDefaultSyntheticPartCacheHint(deps.syntheticPartCacheHint)
+          : undefined;
+        try {
+          const v1messages = event.messages.map((m) => ({
+            info: m,
+            parts: m.content,
+          }));
+          await messagesTransform({}, { messages: v1messages });
+          event.messages = v1messages.map((m) => {
+            const info = m.info as { content?: unknown };
+            info.content = m.parts;
+            return m.info;
+          }) as V2SessionContextEvent['messages'];
+        } catch (err) {
+          log('[v2] messages transform bridge failed', String(err));
+        } finally {
+          restoreCacheHint?.();
+        }
+      });
     }
   };
 }
@@ -391,10 +399,13 @@ function v1ModelFromContext(
 
 export interface V2SessionPromptBridge {
   /** `ctx.session.hook("prompt")` handler — one v1 chat.message delivery
-   * per admitted input (dedupe by messageID). */
+   * per admitted input (dedupe by messageID). The FIRST admission per
+   * session is deferred until the agent is learned (see
+   * `observeContext`) so it is delivered with parts + agent together. */
   handlePrompt(event: V2SessionPromptEvent): Promise<void>;
   /** Record per-session agent/model from context events; forward NEWLY
-   * learned state to the v1 chat.message hook. */
+   * learned state to the v1 chat.message hook, flushing any deferred
+   * first admission with the agent attached. */
   observeContext(event: V2SessionContextEvent): Promise<void>;
   /** Latest agent known for a session from the learned state above (the
    * identity source for transcript user-message enrichment). */
@@ -418,6 +429,22 @@ export interface V2SessionPromptBridge {
  * first-seen/changed state — preserving the v1 timing where the session
  * agent is known before the first tool call of a turn.
  *
+ * First-admission deferral: the v1 chat.message handler only registers
+ * the session agent (sessionMetadata.setAgent) when a delivery carries
+ * one, and its consumers gate on that registration
+ * (shouldManageSession → getAgent === 'orchestrator'). Forwarding the
+ * FIRST admitted prompt before any agent was learned would therefore be
+ * dropped by every consumer, and the follow-up agent-only forward (no
+ * parts) is dropped by the parts gate — the first external message's
+ * state effects (input-wait latch clearing, wake-progress rearm) would
+ * be lost. The bridge instead latches that first prompt per session and
+ * flushes it once the first agent-bearing context event arrives (parts +
+ * agent delivered together, mirroring v1's single chat.message). Bounded
+ * fallbacks keep delivery from being lost outright when no agent is ever
+ * learned: the next admitted prompt for the session flushes a
+ * still-pending one best-known, and so does a context event whose
+ * trailing user message shows the conversation has moved past it.
+ *
  * Child-session filtering: none, deliberately — the context-hook
  * emulation never filtered child sessions either, and every consumer
  * gates itself (e.g. `shouldManageSession`).
@@ -432,6 +459,9 @@ export function createSessionPromptBridge(
     string,
     { agent?: string; model?: { providerID: string; modelID: string } }
   >();
+  /** First admitted prompt per session, deferred until the agent is
+   * learned from a context event (bounded: one per session). */
+  const pendingPrompts = new Map<string, V1ChatMessageInput>();
 
   function trailingUserId(event: V2SessionContextEvent): string | undefined {
     const id = [...event.messages]
@@ -440,6 +470,17 @@ export function createSessionPromptBridge(
     return typeof id === 'string' && id ? id : undefined;
   }
 
+  async function deliver(
+    label: string,
+    input: V1ChatMessageInput,
+  ): Promise<void> {
+    try {
+      await chatMessage(input, undefined);
+    } catch (err) {
+      log(`[v2] ${label} chat.message bridge failed`, String(err));
+    }
+  }
+
   return {
     async handlePrompt(event) {
       if (!event || typeof event !== 'object') return;
@@ -484,20 +525,29 @@ export function createSessionPromptBridge(
           if (isRecord(file)) parts.push({ type: 'file', ...file });
         }
       }
-      try {
-        await chatMessage(
-          {
-            sessionID,
-            messageID,
-            ...(state?.agent ? { agent: state.agent } : {}),
-            ...(state?.model ? { model: state.model } : {}),
-            ...(parts.length > 0 ? { parts } : {}),
-          },
-          undefined,
-        );
-      } catch (err) {
-        log('[v2] prompt-hook chat.message bridge failed', String(err));
+      const input: V1ChatMessageInput = {
+        sessionID,
+        messageID,
+        ...(state?.agent ? { agent: state.agent } : {}),
+        ...(state?.model ? { model: state.model } : {}),
+        ...(parts.length > 0 ? { parts } : {}),
+      };
+      if (state?.agent) {
+        await deliver('prompt-hook', input);
+        return;
       }
+      // Agent not yet learned: forwarding now would be dropped by every
+      // v1 consumer (see the first-admission deferral note above). Latch
+      // the prompt; the first agent-bearing context event flushes it with
+      // the agent attached. Bounded fallback: a still-pending prompt is
+      // flushed best-known when the next admission arrives, so delivery
+      // is deferred, never lost.
+      const pending = pendingPrompts.get(sessionID);
+      if (pending) {
+        await deliver('prompt-hook', pending);
+      }
+      pendingPrompts.set(sessionID, input);
+      pruneSessionMap(pendingPrompts);
     },
 
     async observeContext(event) {
@@ -510,37 +560,56 @@ export function createSessionPromptBridge(
           : undefined;
       const model = v1ModelFromContext(event.model);
       const previous = sessionState.get(sessionID);
-      if (
-        previous &&
+      const unchanged =
+        !!previous &&
         previous.agent === agent &&
         ((previous.model === undefined && model === undefined) ||
           (previous.model !== undefined &&
             model !== undefined &&
             previous.model.providerID === model.providerID &&
-            previous.model.modelID === model.modelID))
-      ) {
-        return; // nothing newly learned — once-per-admission fidelity holds
+            previous.model.modelID === model.modelID));
+      if (!unchanged) {
+        sessionState.set(sessionID, {
+          ...(agent ? { agent } : {}),
+          ...(model ? { model } : {}),
+        });
+        pruneSessionMap(sessionState);
+      }
+      const pending = pendingPrompts.get(sessionID);
+      if (pending) {
+        if (agent && previous?.agent !== agent) {
+          // Agent newly learned: flush the deferred first admission with
+          // the agent attached — one delivery carrying parts + agent
+          // together, so the v1 chat.message handler registers the
+          // session agent BEFORE its consumers gate on it. This flush
+          // supersedes the no-parts state forward below (same trailing
+          // messageID, strictly more information).
+          pendingPrompts.delete(sessionID);
+          await deliver('agent-discovery', {
+            ...pending,
+            agent,
+            ...(model ? { model } : {}),
+          });
+          return;
+        }
+        if (
+          trailingUserId(event) &&
+          trailingUserId(event) !== pending.messageID
+        ) {
+          // The conversation moved past the pending admission without the
+          // agent ever being learned (e.g. a synthetic/compaction request
+          // followed): flush best-known so the delivery is not lost.
+          pendingPrompts.delete(sessionID);
+          await deliver('agent-discovery', pending);
+        }
       }
-      sessionState.set(sessionID, {
+      if (unchanged) return; // nothing newly learned — once-per-admission fidelity holds
+      await deliver('agent-discovery', {
+        sessionID,
         ...(agent ? { agent } : {}),
         ...(model ? { model } : {}),
+        ...(trailingUserId(event) ? { messageID: trailingUserId(event) } : {}),
       });
-      pruneSessionMap(sessionState);
-      try {
-        await chatMessage(
-          {
-            sessionID,
-            ...(agent ? { agent } : {}),
-            ...(model ? { model } : {}),
-            ...(trailingUserId(event)
-              ? { messageID: trailingUserId(event) }
-              : {}),
-          },
-          undefined,
-        );
-      } catch (err) {
-        log('[v2] agent-discovery chat.message bridge failed', String(err));
-      }
     },
 
     agentForSession(sessionID) {