pi-openai-toolkit 0.14.7 → 0.14.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,3 +1,4 @@
1
+ import type { AgentMessage } from "@earendil-works/pi-agent-core";
1
2
  import type {
2
3
  BeforeProviderRequestEvent,
3
4
  ContextEvent,
@@ -18,8 +19,22 @@ import { loadHistoryNotesThreadHint } from "./context-management/history-notes";
18
19
  import { CodexContextWindowManager } from "./context-management/window-manager";
19
20
  import { registerContextManagementTools } from "./context-management/tools";
20
21
  import { writeDebugArtifact, writeReplayFailureArtifact } from "./debug";
22
+ import {
23
+ COMPACTION_CHECKPOINT_PROVENANCE_UNAVAILABLE,
24
+ COMPACTION_PROJECTION_UNAVAILABLE,
25
+ COMPACTION_SESSION_CONTEXT_UNAVAILABLE,
26
+ COMPACTION_PROJECTED_CONTEXT_UNAVAILABLE,
27
+ hasVerifiedCompactionInputProvenance,
28
+ type UnprojectedCompactionReason,
29
+ } from "./compaction-projection";
21
30
  import { CODEX_GATEWAY_FORWARD_HEADERS } from "./responses-headers";
22
31
  import { resolveLatestNativeCompactionEntry } from "./details-store";
32
+ import {
33
+ getPiContextHookProjector,
34
+ installPiContextHookPatch,
35
+ reportPiContextHookFailure,
36
+ type PiContextHookPatchResult,
37
+ } from "./pi-context-hook";
23
38
  import { runNativeFallbackCompaction } from "./native-fallback";
24
39
  import {
25
40
  rewriteResponsesPayloadWithNativeReplay,
@@ -27,29 +42,44 @@ import {
27
42
  serializeLiveTailToResponsesInput,
28
43
  } from "./payload-rewrite";
29
44
  import { getCompactionRequestExtras, rememberRequestContext } from "./request-context-cache";
45
+ import { resolveWebSearchRoute } from "./web-search/types";
30
46
  import { executeRemoteV2Compaction } from "./remote-v2-client";
31
47
  import {
32
48
  resolveNativeCompactionEnvironment,
33
49
  resolveRemoteCompactionExecution,
34
50
  type RemoteCompactionExecution,
35
51
  } from "./runtime";
36
- import { serializeMessagesToCompactRequest, type NativeCompactionRequestBody, type ResponsesInputItem } from "./serializer";
52
+ import {
53
+ serializeMessagesToCompactRequest,
54
+ serializeMessagesToResponsesInput,
55
+ type NativeCompactionRequestBody,
56
+ type ResponsesInputItem,
57
+ } from "./serializer";
37
58
  import {
38
59
  createNativeCompactionDetails,
39
60
  createNativeCompactionResult,
40
61
  COMPACTION_EXTENSION_ID,
41
62
  getLatestDeferredToolCarryover,
63
+ getRemoteV2InputProvenance,
42
64
  isNativeCompactionDetails,
43
65
  type CompactionConfig,
44
66
  type NativeCompactionDetails,
45
67
  type NativeCompactionRequestMeta,
46
68
  } from "./types";
47
69
 
70
+ type CompactionContextProjection = (
71
+ messages: readonly AgentMessage[],
72
+ ctx: ExtensionContext,
73
+ ) => readonly AgentMessage[] | undefined | Promise<readonly AgentMessage[] | undefined>;
74
+
48
75
  type CompactionDependencies = {
49
76
  loadConfig: typeof loadToolkitConfig;
50
77
  remoteCompact: typeof executeRemoteV2Compaction;
51
78
  nativeFallback: typeof runNativeFallbackCompaction;
52
79
  contextWindows: CodexContextWindowManager;
80
+ /** Test seam for the same ordered projection exposed by the Pi host patch. */
81
+ projectCompactionContext?: CompactionContextProjection;
82
+ piContextHookPatch: PiContextHookPatchResult;
53
83
  };
54
84
 
55
85
  type RemoteContextActive = (
@@ -61,7 +91,11 @@ type RemoteContextActive = (
61
91
  type ResponsesCompactOutcome =
62
92
  | { outcome: "success"; compaction: CompactionResult<NativeCompactionDetails> }
63
93
  | { outcome: "aborted" }
64
- | { outcome: "failed" };
94
+ | { outcome: "failed" }
95
+ | {
96
+ outcome: "unprojected-input";
97
+ reason: UnprojectedCompactionReason;
98
+ };
65
99
 
66
100
  function buildCompactionRequestMeta(event: SessionBeforeCompactEvent): NativeCompactionRequestMeta {
67
101
  return {
@@ -142,12 +176,86 @@ function buildCompactionInstructions(systemPrompt: string, customInstructions?:
142
176
  return `${systemPrompt}\n\nAdditional user guidance for this manual /compact request:\n${guidance}`;
143
177
  }
144
178
 
179
+ function findLatestProjectedCompactionSummaryIndex(
180
+ messages: readonly AgentMessage[],
181
+ summary: string,
182
+ ): number {
183
+ for (let index = messages.length - 1; index >= 0; index -= 1) {
184
+ const message = messages[index];
185
+ if (message?.role === "compactionSummary" && message.summary === summary) {
186
+ return index;
187
+ }
188
+ }
189
+
190
+ return -1;
191
+ }
192
+
193
+ type SessionManagerWithOptionalContext = ExtensionContext["sessionManager"] & {
194
+ buildSessionContext?: () => { messages?: readonly AgentMessage[] };
195
+ };
196
+
197
+ function readSessionContextMessages(ctx: ExtensionContext): AgentMessage[] | undefined {
198
+ const sessionManager = ctx.sessionManager as SessionManagerWithOptionalContext;
199
+ if (typeof sessionManager.buildSessionContext !== "function") {
200
+ return undefined;
201
+ }
202
+
203
+ try {
204
+ const sessionContext = sessionManager.buildSessionContext();
205
+ if (!sessionContext || !Array.isArray(sessionContext.messages)) {
206
+ return undefined;
207
+ }
208
+ return structuredClone([...sessionContext.messages]);
209
+ } catch {
210
+ return undefined;
211
+ }
212
+ }
213
+
214
+ type CompactionProjectionResult =
215
+ | { ok: true; messages: readonly AgentMessage[] }
216
+ | { ok: false; reason: typeof COMPACTION_PROJECTION_UNAVAILABLE | typeof COMPACTION_SESSION_CONTEXT_UNAVAILABLE };
217
+
218
+ async function projectSessionContextForCompaction(
219
+ ctx: ExtensionContext,
220
+ projectCompactionContext: CompactionContextProjection | undefined,
221
+ ): Promise<CompactionProjectionResult> {
222
+ const messages = readSessionContextMessages(ctx);
223
+ if (!messages) {
224
+ return { ok: false, reason: COMPACTION_SESSION_CONTEXT_UNAVAILABLE };
225
+ }
226
+
227
+ const projector = projectCompactionContext ?? getPiContextHookProjector(ctx);
228
+ if (!projector) {
229
+ return { ok: false, reason: COMPACTION_PROJECTION_UNAVAILABLE };
230
+ }
231
+
232
+ try {
233
+ const projected = await projector(messages, ctx);
234
+ return Array.isArray(projected)
235
+ ? { ok: true, messages: projected }
236
+ : { ok: false, reason: COMPACTION_PROJECTION_UNAVAILABLE };
237
+ } catch {
238
+ return { ok: false, reason: COMPACTION_PROJECTION_UNAVAILABLE };
239
+ }
240
+ }
241
+
242
+ function getLegacySessionContextMessages(
243
+ event: SessionBeforeCompactEvent,
244
+ ctx: ExtensionContext,
245
+ ): AgentMessage[] {
246
+ return readSessionContextMessages(ctx) ?? [
247
+ ...event.preparation.messagesToSummarize,
248
+ ...event.preparation.turnPrefixMessages,
249
+ ];
250
+ }
251
+
145
252
  async function runResponsesNativeCompact(
146
253
  event: SessionBeforeCompactEvent,
147
254
  ctx: ExtensionContext,
148
255
  config: CompactionConfig,
149
256
  execution: RemoteCompactionExecution,
150
257
  remoteCompact: typeof executeRemoteV2Compaction,
258
+ projectCompactionContext: CompactionContextProjection | undefined,
151
259
  ): Promise<ResponsesCompactOutcome> {
152
260
  const { consumer, compactor } = execution;
153
261
  const instructions = buildCompactionInstructions(ctx.getSystemPrompt(), event.customInstructions);
@@ -157,24 +265,61 @@ async function runResponsesNativeCompact(
157
265
  baseUrl: consumer.baseUrl,
158
266
  });
159
267
 
268
+ const inputProvenance = getRemoteV2InputProvenance(config.remoteV2ContextSource);
160
269
  let requestSource: "session-context" | "non-native-session-context" | "latest-native-replay";
161
270
  let request: NativeCompactionRequestBody;
162
271
  if (latestNativeCompaction.ok) {
163
- const liveTailEntries = branchEntries.slice(latestNativeCompaction.index + 1);
164
272
  const details = latestNativeCompaction.entry.details;
165
273
  if (!details) {
166
274
  return { outcome: "failed" };
167
275
  }
276
+ if (!hasVerifiedCompactionInputProvenance(details, inputProvenance)) {
277
+ return {
278
+ outcome: "unprojected-input",
279
+ reason: COMPACTION_CHECKPOINT_PROVENANCE_UNAVAILABLE,
280
+ };
281
+ }
168
282
  requestSource = "latest-native-replay";
169
- const input: ResponsesInputItem[] = [
170
- ...(cloneOpaqueWindow(details.compactedWindow) as ResponsesInputItem[]),
171
- ...serializeLiveTailToResponsesInput({ model: compactor.currentModel, entries: liveTailEntries }),
172
- ];
173
- request = {
174
- model: compactor.model,
175
- input,
176
- instructions,
177
- };
283
+
284
+ if (config.remoteV2ContextSource === "pi-context-hook") {
285
+ const projection = await projectSessionContextForCompaction(ctx, projectCompactionContext);
286
+ if (!projection.ok) {
287
+ return { outcome: "unprojected-input", reason: projection.reason };
288
+ }
289
+ const summaryIndex = findLatestProjectedCompactionSummaryIndex(
290
+ projection.messages,
291
+ latestNativeCompaction.entry.summary,
292
+ );
293
+ if (summaryIndex < 0) {
294
+ return {
295
+ outcome: "unprojected-input",
296
+ reason: COMPACTION_PROJECTED_CONTEXT_UNAVAILABLE,
297
+ };
298
+ }
299
+ const input: ResponsesInputItem[] = [
300
+ ...(cloneOpaqueWindow(details.compactedWindow) as ResponsesInputItem[]),
301
+ ...serializeMessagesToResponsesInput(
302
+ compactor.currentModel,
303
+ [...projection.messages.slice(summaryIndex + 1)],
304
+ ),
305
+ ];
306
+ request = {
307
+ model: compactor.model,
308
+ input,
309
+ instructions,
310
+ };
311
+ } else {
312
+ const liveTailEntries = branchEntries.slice(latestNativeCompaction.index + 1);
313
+ const input: ResponsesInputItem[] = [
314
+ ...(cloneOpaqueWindow(details.compactedWindow) as ResponsesInputItem[]),
315
+ ...serializeLiveTailToResponsesInput({ model: compactor.currentModel, entries: liveTailEntries }),
316
+ ];
317
+ request = {
318
+ model: compactor.model,
319
+ input,
320
+ instructions,
321
+ };
322
+ }
178
323
  } else if (
179
324
  latestNativeCompaction.reason === "no-compaction" ||
180
325
  (latestNativeCompaction.reason === "latest-compaction-not-native" &&
@@ -182,18 +327,23 @@ async function runResponsesNativeCompact(
182
327
  ) {
183
328
  requestSource =
184
329
  latestNativeCompaction.reason === "no-compaction" ? "session-context" : "non-native-session-context";
185
- const sessionManagerWithContext = ctx.sessionManager as typeof ctx.sessionManager & {
186
- buildSessionContext?: () => { messages: Parameters<typeof serializeMessagesToCompactRequest>[0]["messages"] };
187
- };
188
- const messages = sessionManagerWithContext.buildSessionContext?.().messages ?? [
189
- ...event.preparation.messagesToSummarize,
190
- ...event.preparation.turnPrefixMessages,
191
- ];
192
- request = serializeMessagesToCompactRequest({
193
- model: compactor.currentModel,
194
- messages,
195
- instructions,
196
- });
330
+ if (config.remoteV2ContextSource === "pi-context-hook") {
331
+ const projection = await projectSessionContextForCompaction(ctx, projectCompactionContext);
332
+ if (!projection.ok) {
333
+ return { outcome: "unprojected-input", reason: projection.reason };
334
+ }
335
+ request = serializeMessagesToCompactRequest({
336
+ model: compactor.currentModel,
337
+ messages: [...projection.messages],
338
+ instructions,
339
+ });
340
+ } else {
341
+ request = serializeMessagesToCompactRequest({
342
+ model: compactor.currentModel,
343
+ messages: getLegacySessionContextMessages(event, ctx),
344
+ instructions,
345
+ });
346
+ }
197
347
  } else {
198
348
  writeDebugArtifact(
199
349
  "compaction-event",
@@ -275,6 +425,7 @@ async function runResponsesNativeCompact(
275
425
  compactResponseId: compactResult.compactResponseId,
276
426
  createdAt: compactResult.createdAt,
277
427
  requestMeta: buildCompactionRequestMeta(event),
428
+ inputProvenance,
278
429
  });
279
430
  } catch (error) {
280
431
  writeDebugArtifact(
@@ -324,6 +475,8 @@ async function runResponsesNativeCompact(
324
475
  baseUrl: compactor.baseUrl,
325
476
  },
326
477
  requestSource,
478
+ remoteV2ContextSource: config.remoteV2ContextSource,
479
+ inputProvenance,
327
480
  requestInputItems: request.input.length,
328
481
  requestExtras: extras ? Object.keys(extras) : [],
329
482
  compactResponseId: compactResult.compactResponseId,
@@ -405,13 +558,47 @@ async function handleSessionBeforeCompact(
405
558
  );
406
559
  if (resolution.ok) {
407
560
  remoteAttempted = true;
408
- const responsesOutcome = await runResponsesNativeCompact(event, ctx, config, resolution.execution, dependencies.remoteCompact);
561
+ const responsesOutcome = await runResponsesNativeCompact(
562
+ event,
563
+ ctx,
564
+ config,
565
+ resolution.execution,
566
+ dependencies.remoteCompact,
567
+ dependencies.projectCompactionContext,
568
+ );
409
569
  if (responsesOutcome.outcome === "success") {
410
570
  return { compaction: responsesOutcome.compaction };
411
571
  }
412
572
  if (responsesOutcome.outcome === "aborted") {
413
573
  return { cancel: true };
414
574
  }
575
+ if (responsesOutcome.outcome === "unprojected-input") {
576
+ const message = responsesOutcome.reason === COMPACTION_PROJECTION_UNAVAILABLE
577
+ ? "Pi's ordered context-hook projection is unavailable; Remote V2 was not sent raw session history."
578
+ : responsesOutcome.reason === COMPACTION_SESSION_CONTEXT_UNAVAILABLE
579
+ ? "Pi did not provide the current session context required by its context-hook projection."
580
+ : responsesOutcome.reason === COMPACTION_PROJECTED_CONTEXT_UNAVAILABLE
581
+ ? "Pi's projected context did not include the current compaction summary anchor."
582
+ : "The latest opaque checkpoint has no marker for the configured Remote V2 context source, or was created in the other mode.";
583
+ writeDebugArtifact(
584
+ "compaction-event",
585
+ {
586
+ event: "session_before_compact.remote-v2-unprojected-input",
587
+ reason: responsesOutcome.reason,
588
+ contextSource: config.remoteV2ContextSource,
589
+ inputProvenance: getRemoteV2InputProvenance(config.remoteV2ContextSource),
590
+ piContextHookPatch: dependencies.piContextHookPatch,
591
+ message,
592
+ },
593
+ config,
594
+ ctx,
595
+ );
596
+ notifyWarning(
597
+ ctx,
598
+ `Remote V2 compaction cancelled (${responsesOutcome.reason}); ${message} Keep the context-source setting aligned with the checkpoint or choose legacy mode explicitly.`,
599
+ );
600
+ return { cancel: true };
601
+ }
415
602
  // failed: fall through to the configured-model fallback below.
416
603
  } else {
417
604
  writeDebugArtifact(
@@ -492,7 +679,7 @@ async function handleSessionBeforeCompact(
492
679
  return undefined;
493
680
  }
494
681
 
495
- async function handleContext(
682
+ async function handleContextInternal(
496
683
  event: ContextEvent,
497
684
  ctx: ExtensionContext,
498
685
  pi: ExtensionAPI,
@@ -513,6 +700,7 @@ async function handleContext(
513
700
  contextWindows.synchronize(ctx);
514
701
  } catch {
515
702
  notifyRemoteContextFailure(ctx, "malformed-window-state");
703
+ reportPiContextHookFailure(ctx, "malformed-window-state");
516
704
  ctx.abort();
517
705
  return undefined;
518
706
  }
@@ -532,6 +720,7 @@ async function handleContext(
532
720
  : { messages: projected };
533
721
  } catch (error) {
534
722
  notifyRemoteContextFailure(ctx, "malformed-window-state");
723
+ reportPiContextHookFailure(ctx, "malformed-window-state");
535
724
  ctx.abort();
536
725
  return undefined;
537
726
  }
@@ -544,6 +733,7 @@ async function handleContext(
544
733
  if (isCodexContextModel(ctx.model, config)) {
545
734
  if (isCodexGatewayModel(ctx.model, config.gatewayContextModels)) {
546
735
  notifyRemoteContextFailure(ctx, "codex-context-unavailable");
736
+ reportPiContextHookFailure(ctx, "codex-context-unavailable");
547
737
  ctx.abort();
548
738
  return undefined;
549
739
  }
@@ -568,16 +758,43 @@ async function handleContext(
568
758
  const branchEntries = ctx.sessionManager.getBranch();
569
759
  const latest = resolveLatestNativeCompactionEntry(branchEntries, { baseUrl: resolution.runtime.baseUrl });
570
760
  if (!latest.ok) return undefined;
571
- const result = removeNativeCompactionRetainedMessages({ messages: replayEvent.messages, branchEntries, compactionEntry: latest.entry });
761
+ const result = removeNativeCompactionRetainedMessages({
762
+ messages: replayEvent.messages,
763
+ branchEntries,
764
+ compactionEntry: latest.entry,
765
+ expectedInputProvenance: getRemoteV2InputProvenance(config.remoteV2ContextSource),
766
+ });
572
767
  if (!result.ok) {
573
768
  writeReplayFailureArtifact({ reason: result.reason, compactionEntryId: latest.entry.id }, config, ctx);
574
769
  if (ctx.hasUI) ctx.ui.notify(`${COMPACTION_EXTENSION_ID}: replay failed (${result.reason}); request aborted`, "error");
770
+ reportPiContextHookFailure(ctx, `replay-failed:${result.reason}`);
575
771
  ctx.abort();
576
772
  return undefined;
577
773
  }
578
774
  return result.messages === replayEvent.messages ? undefined : { messages: result.messages };
579
775
  }
580
776
 
777
+ /**
778
+ * Pi's emitContext() catches handler exceptions. Re-throw as usual for Pi, but
779
+ * also report the failure so the compaction projector cannot mistake the
780
+ * caught exception for a successful projection.
781
+ */
782
+ async function handleContext(
783
+ event: ContextEvent,
784
+ ctx: ExtensionContext,
785
+ pi: ExtensionAPI,
786
+ loadConfig: typeof loadToolkitConfig,
787
+ contextWindows: CodexContextWindowManager,
788
+ remoteContextActive: RemoteContextActive,
789
+ ) {
790
+ try {
791
+ return await handleContextInternal(event, ctx, pi, loadConfig, contextWindows, remoteContextActive);
792
+ } catch (error) {
793
+ reportPiContextHookFailure(ctx, error instanceof Error ? error.message : String(error));
794
+ throw error;
795
+ }
796
+ }
797
+
581
798
  async function handleBeforeProviderRequest(
582
799
  event: BeforeProviderRequestEvent,
583
800
  ctx: ExtensionContext,
@@ -660,13 +877,20 @@ async function handleBeforeProviderRequest(
660
877
  // synthetic compact request using the active consumer's effective runtime identity.
661
878
  // This hook runs before the separate Web Search transform, so injected native search
662
879
  // tools are not copied into remote_compaction_v2.
663
- rememberRequestContext(payload, {
664
- provider: runtime.provider,
665
- api: runtime.api,
666
- model: runtime.model,
667
- baseUrl: runtime.baseUrl,
668
- sessionId: getSessionId(ctx),
669
- });
880
+ const webSearchRoute = resolveWebSearchRoute({ model: ctx.model, config: toolkitConfig.webSearch });
881
+ rememberRequestContext(
882
+ payload,
883
+ {
884
+ provider: runtime.provider,
885
+ api: runtime.api,
886
+ model: runtime.model,
887
+ baseUrl: runtime.baseUrl,
888
+ sessionId: getSessionId(ctx),
889
+ },
890
+ {
891
+ excludeWebSearchTools: webSearchRoute.route === "standalone-alpha",
892
+ },
893
+ );
670
894
 
671
895
  const branchEntries = ctx.sessionManager.getBranch();
672
896
  const latestNativeCompaction = resolveLatestNativeCompactionEntry(branchEntries, {
@@ -699,6 +923,7 @@ async function handleBeforeProviderRequest(
699
923
  payload,
700
924
  branchEntries,
701
925
  compactionEntry: latestNativeCompactionEntry,
926
+ expectedInputProvenance: getRemoteV2InputProvenance(config.remoteV2ContextSource),
702
927
  });
703
928
  if (!rewrite.ok) {
704
929
  writeDebugArtifact(
@@ -776,6 +1001,7 @@ export default function registerCompactionExtension(
776
1001
  overrides: Partial<CompactionDependencies> = {},
777
1002
  ) {
778
1003
  const loadConfig = overrides.loadConfig ?? loadToolkitConfig;
1004
+ const piContextHookPatch = installPiContextHookPatch();
779
1005
  const contextWindows = overrides.contextWindows ?? new CodexContextWindowManager((ctx, signal) =>
780
1006
  loadHistoryNotesThreadHint(ctx, signal, loadConfig().config.compaction.gatewayContextModels)
781
1007
  );
@@ -785,6 +1011,7 @@ export default function registerCompactionExtension(
785
1011
  nativeFallback: runNativeFallbackCompaction,
786
1012
  contextWindows,
787
1013
  ...overrides,
1014
+ piContextHookPatch: overrides.piContextHookPatch ?? piContextHookPatch,
788
1015
  };
789
1016
  let tools!: ReturnType<typeof registerContextManagementTools>;
790
1017
  tools = registerContextManagementTools(
@@ -847,9 +1074,11 @@ export default function registerCompactionExtension(
847
1074
  activation: {
848
1075
  active,
849
1076
  contextManagement: config.contextManagement,
1077
+ remoteV2ContextSource: config.remoteV2ContextSource,
850
1078
  model: ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined,
851
1079
  ...(activationReason ? { reason: activationReason } : {}),
852
1080
  },
1081
+ piContextHookPatch: dependencies.piContextHookPatch,
853
1082
  },
854
1083
  config,
855
1084
  ctx,
@@ -8,8 +8,12 @@ import type {
8
8
  SessionMessageEntry,
9
9
  } from "@earendil-works/pi-coding-agent";
10
10
  import type { ResponsesCompatibleRequestPayload } from "./runtime";
11
+ import { hasVerifiedCompactionInputProvenance } from "./compaction-projection";
11
12
  import { rewritePayloadWithDeferredToolCarryover } from "./deferred-tool-carryover";
12
- import type { NativeCompactionEntry } from "./types";
13
+ import type {
14
+ NativeCompactionEntry,
15
+ NativeCompactionInputProvenance,
16
+ } from "./types";
13
17
  import { serializeMessagesToResponsesInput, type ResponsesInputContentItem, type ResponsesInputItem, type ResponsesInputMessageItem } from "./serializer";
14
18
 
15
19
  export type NativeReplaySegments = {
@@ -35,7 +39,8 @@ export type NativeReplayPayloadRewriteFailureReason =
35
39
  | "invalid-compacted-window"
36
40
  | "unexpected-compaction-after-boundary"
37
41
  | "compaction-summary-not-found"
38
- | "retained-context-mismatch";
42
+ | "retained-context-mismatch"
43
+ | "unverified-compaction-input";
39
44
 
40
45
  export type NativeReplayPayloadRewriteFailure = {
41
46
  ok: false;
@@ -323,29 +328,60 @@ export function removeNativeCompactionRetainedMessages(args: {
323
328
  messages: AgentMessage[];
324
329
  branchEntries: readonly SessionEntry[];
325
330
  compactionEntry: NativeCompactionEntry;
331
+ expectedInputProvenance: NativeCompactionInputProvenance;
326
332
  }): { ok: true; messages: AgentMessage[] } | NativeReplayPayloadRewriteFailure {
327
333
  const boundary = findCompactionBoundaryIndex(args.branchEntries, args.compactionEntry.id);
328
334
  if (boundary === undefined) return { ok: false, reason: "compaction-boundary-not-found" };
335
+ if (!hasVerifiedCompactionInputProvenance(args.compactionEntry.details, args.expectedInputProvenance)) {
336
+ return { ok: false, reason: "unverified-compaction-input" };
337
+ }
329
338
  const firstKept = findEntryIndexByIdBeforeBoundary(args.branchEntries, args.compactionEntry.firstKeptEntryId, boundary);
330
339
  if (firstKept === undefined) return { ok: false, reason: "first-kept-entry-not-found" };
331
- const summary = sessionEntryToContextMessages(args.compactionEntry)[0];
340
+ const summary = structuredClone(sessionEntryToContextMessages(args.compactionEntry)[0]);
332
341
  const summaryIndex = args.messages.findIndex((message) => areEquivalentValues(message, summary));
333
342
  if (summaryIndex < 0) return { ok: false, reason: "compaction-summary-not-found" };
334
- const retained = args.branchEntries.slice(firstKept, boundary).flatMap(sessionEntryToContextMessages);
343
+ const retained = args.branchEntries
344
+ .slice(firstKept, boundary)
345
+ .flatMap(sessionEntryToContextMessages)
346
+ .map((message) => structuredClone(message));
347
+ // Custom messages are optional context-hook entries: a hook may filter or
348
+ // rewrite them before provider serialization. An exact custom copy is still
349
+ // removed when present, while a missing or rewritten copy is left alone.
350
+ // Required user/assistant/tool messages keep the ordered, fail-closed
351
+ // matching contract.
352
+ const requiredRetained = retained.filter((message) => message.role !== "custom");
353
+ const optionalRetained = retained.filter((message) => message.role === "custom");
335
354
  const removed = new Set<number>();
336
355
  let cursor = summaryIndex + 1;
337
- for (const expected of retained) {
356
+ for (const expected of requiredRetained) {
338
357
  const index = args.messages.findIndex((message, index) => index >= cursor && areEquivalentValues(message, expected));
339
358
  if (index >= 0) {
340
359
  removed.add(index);
341
360
  cursor = index + 1;
342
361
  }
343
362
  }
344
- // Already removed by another context handler: leave it alone. Partial
345
- // matches are ambiguous; never remove half a call/result batch.
346
- if (removed.size === 0) return { ok: true, messages: args.messages };
347
- if (removed.size !== retained.length) return { ok: false, reason: "retained-context-mismatch" };
348
- return { ok: true, messages: args.messages.filter((_, index) => !removed.has(index)) };
363
+ // An empty required span is valid. For a non-empty span, zero matches is
364
+ // ambiguous with the entire required history having been removed or changed;
365
+ // fail closed instead of treating it as an already-filtered no-op. Partial
366
+ // matches are equally unsafe because they can remove half a call/result batch.
367
+ if (requiredRetained.length > 0 && removed.size !== requiredRetained.length) {
368
+ return { ok: false, reason: "retained-context-mismatch" };
369
+ }
370
+
371
+ // Optional entries do not affect the strict parity decision. Search for each
372
+ // exact copy independently so a reordered custom entry can still be removed,
373
+ // but a provider-visible metadata/content rewrite remains untouched.
374
+ for (const expected of optionalRetained) {
375
+ const index = args.messages.findIndex(
376
+ (message, candidateIndex) =>
377
+ candidateIndex > summaryIndex && !removed.has(candidateIndex) && areEquivalentValues(message, expected),
378
+ );
379
+ if (index >= 0) removed.add(index);
380
+ }
381
+
382
+ return removed.size === 0
383
+ ? { ok: true, messages: args.messages }
384
+ : { ok: true, messages: args.messages.filter((_, index) => !removed.has(index)) };
349
385
  }
350
386
 
351
387
  export function serializeLiveTailToResponsesInput<TApi extends Api>(args: {
@@ -360,6 +396,7 @@ function buildNativeReplaySegmentsInternal<TApi extends Api>(args: {
360
396
  payload: ResponsesCompatibleRequestPayload;
361
397
  branchEntries: readonly SessionEntry[];
362
398
  compactionEntry: NativeCompactionEntry;
399
+ expectedInputProvenance: NativeCompactionInputProvenance;
363
400
  }): NativeReplayPayloadRewriteResult {
364
401
  const boundaryIndex = findCompactionBoundaryIndex(args.branchEntries, args.compactionEntry.id);
365
402
  if (boundaryIndex === undefined) {
@@ -398,6 +435,12 @@ function buildNativeReplaySegmentsInternal<TApi extends Api>(args: {
398
435
  reason: "invalid-compacted-window",
399
436
  };
400
437
  }
438
+ if (!hasVerifiedCompactionInputProvenance(details, args.expectedInputProvenance)) {
439
+ return {
440
+ ok: false,
441
+ reason: "unverified-compaction-input",
442
+ };
443
+ }
401
444
 
402
445
  const compactedWindow = cloneOpaqueCompactedWindow(details.compactedWindow);
403
446
  if (!compactedWindow) {
@@ -462,6 +505,7 @@ export function buildNativeReplaySegments<TApi extends Api>(args: {
462
505
  payload: ResponsesCompatibleRequestPayload;
463
506
  branchEntries: readonly SessionEntry[];
464
507
  compactionEntry: NativeCompactionEntry;
508
+ expectedInputProvenance: NativeCompactionInputProvenance;
465
509
  }): NativeReplayPayloadRewriteResult {
466
510
  return buildNativeReplaySegmentsInternal(args);
467
511
  }
@@ -471,6 +515,7 @@ export function rewriteResponsesPayloadWithNativeReplay<TApi extends Api>(args:
471
515
  payload: ResponsesCompatibleRequestPayload;
472
516
  branchEntries: readonly SessionEntry[];
473
517
  compactionEntry: NativeCompactionEntry;
518
+ expectedInputProvenance: NativeCompactionInputProvenance;
474
519
  }): NativeReplayPayloadRewriteResult {
475
520
  return buildNativeReplaySegmentsInternal(args);
476
521
  }