@oh-my-pi/pi-ai 18.4.1 → 18.4.3

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.
@@ -59,7 +59,6 @@ import {
59
59
  GrepContentResultSchema,
60
60
  GrepCountResultSchema,
61
61
  GrepErrorSchema,
62
- type GrepFileCount,
63
62
  GrepFileCountSchema,
64
63
  GrepFileMatchSchema,
65
64
  GrepFilesResultSchema,
@@ -99,6 +98,7 @@ import {
99
98
  McpToolResultSchema,
100
99
  ModelDetailsSchema,
101
100
  ReadErrorSchema,
101
+ ReadFileNotFoundSchema,
102
102
  ReadMcpResourceErrorSchema,
103
103
  type ReadMcpResourceExecResult,
104
104
  ReadMcpResourceExecResultSchema,
@@ -233,7 +233,8 @@ import {
233
233
  buildPiWriteError,
234
234
  buildPiWriteRejected,
235
235
  buildPiWriteResult,
236
- cursorEditOwnedReadPath,
236
+ cursorExecReadPath,
237
+ cursorRawReadPath,
237
238
  omitUndefinedArgs,
238
239
  piEscapeRegexLiteral,
239
240
  piGrepSkip,
@@ -1654,39 +1655,93 @@ async function handleExecServerMessage(
1654
1655
  const args = execMsg.message.value;
1655
1656
  if (!args.toolCallId) args.toolCallId = crypto.randomUUID();
1656
1657
  const editOwned = isEditOwnedToolCallId(state, output, args.toolCallId);
1657
- // Native StrReplace materializes by reading then writing the same
1658
- // toolCallId. The server treats the read result as file bytes, so a
1659
- // hashline-formatted native read would be written back as markup.
1660
- // Force `:raw` and skip the extra transcript block — the edit card
1661
- // already owns this id.
1662
- const composed = editOwned ? cursorEditOwnedReadPath(args.path, args.offset, args.limit) : args.path;
1663
- const handlerArgs = editOwned
1664
- ? composed === null
1658
+ // Cursor numbers raw content itself; StrReplace also writes it back.
1659
+ // Neither frame may receive hashline gutters or summarized bodies.
1660
+ // Edit-owned reads omit the extra transcript block owned by the edit card.
1661
+ // The handler needs the original negative offset to resolve it against
1662
+ // the file length; positive windows can be composed before dispatch.
1663
+ const negativeOffset = args.offset !== undefined && args.offset < 0;
1664
+ const composed = negativeOffset
1665
+ ? cursorRawReadPath(args.path)
1666
+ : cursorExecReadPath(args.path, args.offset, args.limit);
1667
+ const handlerArgs =
1668
+ composed === null
1665
1669
  ? args
1666
- : { ...args, path: composed, offset: undefined, limit: undefined }
1667
- : args;
1670
+ : {
1671
+ ...args,
1672
+ path: composed,
1673
+ offset: negativeOffset ? args.offset : undefined,
1674
+ limit: negativeOffset ? args.limit : undefined,
1675
+ };
1668
1676
  if (!editOwned) {
1669
- // The same composed selector the bridge executes: showing a bare path
1670
- // for a ranged read makes the returned slice look like the whole
1671
- // file in every rebuilt transcript.
1672
- synthesizeCursorExecToolCall(output, stream, state, args.toolCallId, "read", {
1673
- path: piReadDisplayPath(args.path, args.offset, args.limit),
1674
- });
1677
+ synthesizeCursorExecToolCall(
1678
+ output,
1679
+ stream,
1680
+ state,
1681
+ args.toolCallId,
1682
+ "read",
1683
+ negativeOffset
1684
+ ? { path: composed, offset: args.offset, limit: args.limit }
1685
+ : { path: piReadDisplayPath(args.path, args.offset, args.limit) },
1686
+ );
1675
1687
  }
1676
- const { execResult } = await resolveExecHandler(
1688
+ const { execResult: readResult, toolResult } = await resolveExecHandler(
1677
1689
  handlerArgs,
1678
1690
  execHandlers?.read?.bind(execHandlers),
1679
1691
  editOwned ? undefined : onToolResult,
1680
- toolResult =>
1692
+ result =>
1681
1693
  buildReadResultFromToolResult(
1682
1694
  args.path,
1683
- toolResult,
1695
+ result,
1684
1696
  args.offset !== undefined || args.limit !== undefined || piReadPathHasRange(args.path),
1685
1697
  ),
1686
1698
  reason => buildReadRejectedResult(args.path, reason),
1687
1699
  error => buildReadErrorResult(args.path, error),
1688
1700
  editOwned ? null : { toolCallId: args.toolCallId, toolName: "read" },
1689
1701
  );
1702
+ let execResult = readResult;
1703
+ // StrReplace writes the returned bytes back. A truncated raw read would
1704
+ // make every replacement below the read limit invisible to the server.
1705
+ if (
1706
+ editOwned &&
1707
+ args.offset === undefined &&
1708
+ args.limit === undefined &&
1709
+ !piReadPathHasRange(args.path) &&
1710
+ toolResult &&
1711
+ !toolResult.isError &&
1712
+ toolResultWasTruncated(toolResult)
1713
+ ) {
1714
+ const details = toolResult.details;
1715
+ const source = details && typeof details === "object" && "meta" in details && details.meta;
1716
+ const path = source && typeof source === "object" && "source" in source && source.source;
1717
+ if (
1718
+ path &&
1719
+ typeof path === "object" &&
1720
+ "type" in path &&
1721
+ path.type === "path" &&
1722
+ "value" in path &&
1723
+ typeof path.value === "string"
1724
+ ) {
1725
+ try {
1726
+ const content = await readEditMaterialization(path.value);
1727
+ execResult =
1728
+ content === null
1729
+ ? buildReadErrorResult(
1730
+ args.path,
1731
+ `File exceeds ${EDIT_MATERIALIZATION_MAX_BYTES} bytes; StrReplace cannot load it whole. Use a line-range read and a targeted edit instead.`,
1732
+ )
1733
+ : buildReadResultFromToolResult(args.path, {
1734
+ ...toolResult,
1735
+ content: [{ type: "text", text: content }],
1736
+ details: { fileSize: Buffer.byteLength(content, "utf8") },
1737
+ });
1738
+ } catch (error) {
1739
+ execResult = buildReadErrorResult(args.path, error instanceof Error ? error.message : String(error));
1740
+ }
1741
+ } else {
1742
+ execResult = buildReadErrorResult(args.path, "Unable to read the complete file for StrReplace");
1743
+ }
1744
+ }
1690
1745
  sendExecClientMessage(h2Request, execMsg, "readResult", execResult);
1691
1746
  return;
1692
1747
  }
@@ -2842,9 +2897,50 @@ function readFileSizeFromDetails(toolResult: ToolResultMessage): number | undefi
2842
2897
  return typeof fileSize === "number" && Number.isSafeInteger(fileSize) && fileSize >= 0 ? fileSize : undefined;
2843
2898
  }
2844
2899
 
2900
+ /**
2901
+ * Largest file a native StrReplace materializes whole; matches the local
2902
+ * `read` tool's whole-file snapshot cap (`SNAPSHOT_MAX_BYTES`).
2903
+ */
2904
+ const EDIT_MATERIALIZATION_MAX_BYTES = 4 * 1024 * 1024;
2905
+
2906
+ /**
2907
+ * Read a file for native StrReplace materialization, or `null` when it exceeds
2908
+ * {@link EDIT_MATERIALIZATION_MAX_BYTES}. The buffer is sized from the handle's
2909
+ * stat and reading stops one byte past it, so a file growing during the read
2910
+ * cannot exhaust memory.
2911
+ *
2912
+ * Throws when the file grew during the read while still under the cap.
2913
+ */
2914
+ async function readEditMaterialization(filePath: string): Promise<string | null> {
2915
+ const handle = await fs.open(filePath, "r");
2916
+ try {
2917
+ const { size } = await handle.stat();
2918
+ if (size > EDIT_MATERIALIZATION_MAX_BYTES) return null;
2919
+ const buffer = Buffer.allocUnsafe(size + 1);
2920
+ let length = 0;
2921
+ while (length < buffer.length) {
2922
+ const { bytesRead } = await handle.read(buffer, length, buffer.length - length, length);
2923
+ if (bytesRead === 0) break;
2924
+ length += bytesRead;
2925
+ }
2926
+ if (length > size) {
2927
+ if (size === EDIT_MATERIALIZATION_MAX_BYTES) return null;
2928
+ throw new Error(`File changed while reading: ${filePath}`);
2929
+ }
2930
+ return buffer.toString("utf8", 0, length);
2931
+ } finally {
2932
+ await handle.close();
2933
+ }
2934
+ }
2935
+
2845
2936
  function buildReadResultFromToolResult(path: string, toolResult: ToolResultMessage, rangeApplied = false) {
2846
2937
  const text = toolResultToText(toolResult);
2847
2938
  if (toolResult.isError) {
2939
+ if (/^Path '.*' not found$/.test(text)) {
2940
+ return create(ReadResultSchema, {
2941
+ result: { case: "fileNotFound", value: create(ReadFileNotFoundSchema, { path }) },
2942
+ });
2943
+ }
2848
2944
  return buildReadErrorResult(path, text || "Read failed");
2849
2945
  }
2850
2946
  // Counting the payload is only the file's length when the payload is the
@@ -2942,7 +3038,7 @@ function buildDeleteResultFromToolResult(path: string, toolResult: ToolResultMes
2942
3038
  value: create(DeleteSuccessSchema, {
2943
3039
  path,
2944
3040
  deletedFile: path,
2945
- fileSize: BigInt(0),
3041
+ fileSize: BigInt(readFileSizeFromDetails(toolResult) ?? 0),
2946
3042
  prevContent: "",
2947
3043
  }),
2948
3044
  },
@@ -2973,7 +3069,14 @@ function buildShellResultFromToolResult(
2973
3069
  ) {
2974
3070
  const output = toolResultToText(toolResult);
2975
3071
  if (toolResult.isError) {
2976
- return buildShellFailureResult(args.command, args.workingDirectory, output || "Shell failed");
3072
+ const details = toolResult.details;
3073
+ const code = details && typeof details === "object" && "exitCode" in details ? details.exitCode : undefined;
3074
+ return buildShellFailureResult(
3075
+ args.command,
3076
+ args.workingDirectory,
3077
+ output || "Shell failed",
3078
+ typeof code === "number" && Number.isInteger(code) ? code : 1,
3079
+ );
2977
3080
  }
2978
3081
  return create(ShellResultSchema, {
2979
3082
  result: {
@@ -2991,14 +3094,14 @@ function buildShellResultFromToolResult(
2991
3094
  });
2992
3095
  }
2993
3096
 
2994
- function buildShellFailureResult(command: string, workingDirectory: string, error: string) {
3097
+ function buildShellFailureResult(command: string, workingDirectory: string, error: string, exitCode = 1) {
2995
3098
  return create(ShellResultSchema, {
2996
3099
  result: {
2997
3100
  case: "failure",
2998
3101
  value: create(ShellFailureSchema, {
2999
3102
  command,
3000
3103
  workingDirectory,
3001
- exitCode: 1,
3104
+ exitCode,
3002
3105
  signal: "",
3003
3106
  stdout: "",
3004
3107
  stderr: error,
@@ -3101,16 +3204,16 @@ function buildGrepResultFromToolResult(
3101
3204
 
3102
3205
  const outputMode = args.outputMode || "content";
3103
3206
  const clientTruncated = toolResultDetailBoolean(toolResult, "truncated");
3104
- const lines = text
3105
- .split("\n")
3106
- .map(line => line.trimEnd())
3107
- .filter(line => line.length > 0 && !line.startsWith("[") && !line.toLowerCase().startsWith("no matches"));
3207
+ const details = toolResult.details;
3208
+ const files =
3209
+ details && typeof details === "object" && "files" in details && Array.isArray(details.files)
3210
+ ? details.files.filter((file): file is string => typeof file === "string")
3211
+ : [];
3108
3212
 
3109
3213
  const workspaceKey = args.path || ".";
3110
3214
  let unionResult: GrepUnionResult;
3111
3215
 
3112
3216
  if (outputMode === "files_with_matches") {
3113
- const files = lines;
3114
3217
  unionResult = create(GrepUnionResultSchema, {
3115
3218
  result: {
3116
3219
  case: "files",
@@ -3128,20 +3231,21 @@ function buildGrepResultFromToolResult(
3128
3231
  },
3129
3232
  });
3130
3233
  } else if (outputMode === "count") {
3131
- const counts = lines
3132
- .map(line => {
3133
- const separatorIndex = line.lastIndexOf(":");
3134
- if (separatorIndex === -1) {
3135
- return null;
3136
- }
3137
- const file = line.slice(0, separatorIndex);
3138
- const count = Number.parseInt(line.slice(separatorIndex + 1), 10);
3139
- if (!file || Number.isNaN(count)) {
3140
- return null;
3141
- }
3142
- return create(GrepFileCountSchema, { file, count });
3143
- })
3144
- .filter((entry): entry is GrepFileCount => entry !== null);
3234
+ const fileMatches =
3235
+ details && typeof details === "object" && "fileMatches" in details && Array.isArray(details.fileMatches)
3236
+ ? details.fileMatches
3237
+ : [];
3238
+ const counts = fileMatches
3239
+ .filter(
3240
+ (entry): entry is { path: string; count: number } =>
3241
+ entry !== null &&
3242
+ typeof entry === "object" &&
3243
+ "path" in entry &&
3244
+ typeof entry.path === "string" &&
3245
+ "count" in entry &&
3246
+ typeof entry.count === "number",
3247
+ )
3248
+ .map(({ path, count }) => create(GrepFileCountSchema, { file: path, count }));
3145
3249
  const totalMatches = counts.reduce((sum, entry) => sum + entry.count, 0);
3146
3250
  unionResult = create(GrepUnionResultSchema, {
3147
3251
  result: {
@@ -3159,22 +3263,40 @@ function buildGrepResultFromToolResult(
3159
3263
  } else {
3160
3264
  const matchMap = new Map<string, Array<{ line: number; content: string; isContextLine: boolean }>>();
3161
3265
  let totalMatchedLines = 0;
3162
-
3163
- for (const line of lines) {
3164
- const matchLine = line.match(/^(.+?):(\d+):\s?(.*)$/);
3165
- const contextLine = line.match(/^(.+?)-(\d+)-\s?(.*)$/);
3166
- const match = matchLine ?? contextLine;
3167
- if (!match) {
3266
+ const directories = new Map<number, string>();
3267
+ let currentFile = files.length === 1 ? files[0] : undefined;
3268
+
3269
+ for (const line of text.split("\n")) {
3270
+ const header = /^(#+) (.+)$/.exec(line);
3271
+ if (header) {
3272
+ const depth = header[1]!.length;
3273
+ const name = header[2]!;
3274
+ for (const level of directories.keys()) {
3275
+ if (level >= depth) directories.delete(level);
3276
+ }
3277
+ const parent = directories.get(depth - 1);
3278
+ const candidate = parent ? `${parent}/${name}` : name;
3279
+ if (name.endsWith("/")) {
3280
+ directories.set(depth, candidate.slice(0, -1));
3281
+ currentFile = undefined;
3282
+ } else {
3283
+ currentFile = files.includes(candidate) ? candidate : candidate.replace(/#[0-9a-f]{4,}$/i, "");
3284
+ }
3168
3285
  continue;
3169
3286
  }
3170
- const [, file, lineNumber, content] = match;
3171
- const isContextLine = Boolean(contextLine);
3172
- const list = matchMap.get(file) ?? [];
3173
- list.push({ line: Number(lineNumber), content, isContextLine });
3174
- matchMap.set(file, list);
3175
- if (!isContextLine) {
3176
- totalMatchedLines += 1;
3287
+ const singleHeader = /^\[(.+)#[0-9a-f]{4,}\]$/i.exec(line);
3288
+ if (singleHeader) {
3289
+ currentFile = files[0] ?? singleHeader[1]!;
3290
+ continue;
3177
3291
  }
3292
+ const match = /^([* ])(\d+)[:|](.*)$/.exec(line);
3293
+ if (!match || !currentFile) continue;
3294
+ const [, marker, lineNumber, content] = match;
3295
+ const isContextLine = marker === " ";
3296
+ const list = matchMap.get(currentFile) ?? [];
3297
+ list.push({ line: Number(lineNumber), content, isContextLine });
3298
+ matchMap.set(currentFile, list);
3299
+ if (!isContextLine) totalMatchedLines++;
3178
3300
  }
3179
3301
 
3180
3302
  const matches = Array.from(matchMap.entries()).map(([file, matches]) =>
@@ -751,9 +751,16 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
751
751
  const responseSignal = options?.signal
752
752
  ? AbortSignal.any([options.signal, responseAbortController.signal])
753
753
  : responseAbortController.signal;
754
+ const onSseEvent = options?.onSseEvent;
754
755
  const chunks = iterateWithIdleTimeout(
755
- readSseJson<CloudCodeAssistResponseChunk>(activeResponse.body, responseSignal, event =>
756
- options?.onSseEvent?.({ event: event.event, data: event.data, raw: [...event.raw] }, model),
756
+ // Attach the observer only when a diagnostic listener exists: any
757
+ // observer turns on per-line raw capture in `readSseJson`.
758
+ readSseJson<CloudCodeAssistResponseChunk>(
759
+ activeResponse.body,
760
+ responseSignal,
761
+ onSseEvent
762
+ ? event => onSseEvent({ event: event.event, data: event.data, raw: [...event.raw] }, model)
763
+ : undefined,
757
764
  ),
758
765
  {
759
766
  firstItemTimeoutMs: firstEventTimeoutMs,
@@ -4,7 +4,7 @@
4
4
 
5
5
  import { scheduler } from "node:timers/promises";
6
6
  import { calculateCost } from "@oh-my-pi/pi-catalog/models";
7
- import { readSseJson } from "@oh-my-pi/pi-utils";
7
+ import { readSseJson, type SseEventObserver } from "@oh-my-pi/pi-utils";
8
8
  import { renderDemotedThinking } from "../dialect/demotion";
9
9
  import { ThinkingFenceStripper } from "../dialect/thinking-fence-strip";
10
10
  import * as AIError from "../error";
@@ -1014,13 +1014,17 @@ export function streamGoogleGenAI<T extends "google-generative-ai" | "google-ver
1014
1014
  let body = await openStream();
1015
1015
  stream.push({ type: "start", partial: output });
1016
1016
 
1017
+ // Attach the observer only when a diagnostic listener exists: any
1018
+ // observer turns on per-line raw capture in `readSseJson`.
1019
+ const onSseEvent = options?.onSseEvent;
1020
+ const sseObserver: SseEventObserver | undefined = onSseEvent
1021
+ ? event => onSseEvent({ event: event.event, data: event.data, raw: [...event.raw] }, model)
1022
+ : undefined;
1017
1023
  // Gemini occasionally finishes with `finishReason: STOP` while emitting only an empty
1018
1024
  // text part and no tool call. Delivered as-is the agent receives a blank message and
1019
1025
  // silently halts mid-task, so retry a bounded number of times before giving up.
1020
1026
  for (let emptyAttempt = 0; ; emptyAttempt++) {
1021
- const googleStream = readSseJson<GenerateContentResponse>(body, options?.signal, event =>
1022
- options?.onSseEvent?.({ event: event.event, data: event.data, raw: [...event.raw] }, model),
1023
- );
1027
+ const googleStream = readSseJson<GenerateContentResponse>(body, options?.signal, sseObserver);
1024
1028
  await consumeGoogleStream({
1025
1029
  googleStream,
1026
1030
  output,
@@ -4730,8 +4730,14 @@ async function openCodexSseEventStream(
4730
4730
  if (!response.body) {
4731
4731
  throw new CodexProviderStreamError("No response body", false);
4732
4732
  }
4733
- return readSseJson<Record<string, unknown>>(response.body, signal, event =>
4734
- onSseEvent?.({ event: event.event, data: event.data, raw: [...event.raw] }, undefined),
4733
+ // Attach the observer only when a diagnostic listener exists: any observer
4734
+ // turns on per-line raw capture in `readSseJson`.
4735
+ return readSseJson<Record<string, unknown>>(
4736
+ response.body,
4737
+ signal,
4738
+ onSseEvent
4739
+ ? event => onSseEvent({ event: event.event, data: event.data, raw: [...event.raw] }, undefined)
4740
+ : undefined,
4735
4741
  );
4736
4742
  }
4737
4743
 
@@ -4,7 +4,13 @@ import { resolveWireModelId } from "@oh-my-pi/pi-catalog/model-thinking";
4
4
  import { calculateCost } from "@oh-my-pi/pi-catalog/models";
5
5
  import type { ResolvedOpenAICompat } from "@oh-my-pi/pi-catalog/types";
6
6
  import { clinePassClientHeaders } from "@oh-my-pi/pi-catalog/wire/cline-pass";
7
- import { $env, logger, parseStreamingJson, parseStreamingJsonThrottled } from "@oh-my-pi/pi-utils";
7
+ import {
8
+ $env,
9
+ logger,
10
+ parseStreamingJson,
11
+ parseStreamingJsonThrottled,
12
+ type ServerSentEvent,
13
+ } from "@oh-my-pi/pi-utils";
8
14
  import { renderDemotedThinking } from "../dialect/demotion";
9
15
  import * as AIError from "../error";
10
16
  import { getKimiCommonHeaders } from "../registry/oauth/kimi";
@@ -16,7 +22,6 @@ import type {
16
22
  MessageAttribution,
17
23
  Model,
18
24
  ProviderSessionState,
19
- RawSseEvent,
20
25
  ServiceTier,
21
26
  StopReason,
22
27
  StreamFunction,
@@ -800,28 +805,29 @@ const streamOpenAICompletionsOnce = (
800
805
  // Track the OpenAI `[DONE]` sentinel independently of `onSseEvent`: it is
801
806
  // the streaming protocol's terminal signal, so a stream that ends with it
802
807
  // completed by server agreement even when no `finish_reason` chunk arrived.
808
+ // It arrives through `onDoneSentinel`, so the diagnostic observer below
809
+ // stays unset (and raw wire-line capture off) when nobody listens.
803
810
  let sawDoneSentinel = false;
804
- const rawSseObserver = (event: RawSseEvent) => {
805
- if (event.data === "[DONE]") sawDoneSentinel = true;
806
- if (onSseEvent) {
807
- if (!event.event && event.data && event.data !== "[DONE]") {
808
- try {
809
- const parsed = JSON.parse(event.data);
810
- const resolvedEvent =
811
- typeof parsed.type === "string"
812
- ? parsed.type
813
- : typeof parsed.object === "string"
814
- ? parsed.object
815
- : null;
816
- if (resolvedEvent) {
817
- event.event = resolvedEvent;
818
- event.raw = [`event: ${resolvedEvent}`, ...event.raw];
819
- }
820
- } catch {}
811
+ const rawSseObserver = onSseEvent
812
+ ? (event: ServerSentEvent) => {
813
+ if (!event.event && event.data && event.data !== "[DONE]") {
814
+ try {
815
+ const parsed = JSON.parse(event.data);
816
+ const resolvedEvent =
817
+ typeof parsed.type === "string"
818
+ ? parsed.type
819
+ : typeof parsed.object === "string"
820
+ ? parsed.object
821
+ : null;
822
+ if (resolvedEvent) {
823
+ event.event = resolvedEvent;
824
+ event.raw = [`event: ${resolvedEvent}`, ...event.raw];
825
+ }
826
+ } catch {}
827
+ }
828
+ onSseEvent({ event: event.event, data: event.data, raw: [...event.raw] }, model);
821
829
  }
822
- onSseEvent(event, model);
823
- }
824
- };
830
+ : undefined;
825
831
  // Assigned once the block helpers exist (they are scoped to the `try`);
826
832
  // the catch handler uses it to close open blocks before emitting the
827
833
  // terminal error so both exit paths obey the same block lifecycle.
@@ -931,6 +937,9 @@ const streamOpenAICompletionsOnce = (
931
937
  // bounds every attempt and backoff sleep — retries cannot
932
938
  // extend the deadline.
933
939
  onSseEvent: rawSseObserver,
940
+ onDoneSentinel: () => {
941
+ sawDoneSentinel = true;
942
+ },
934
943
  });
935
944
  // Disarm the first-event watchdog as soon as headers arrive — a slow
936
945
  // onResponse callback must not abort an already-connected stream.
@@ -1030,9 +1039,19 @@ const streamOpenAICompletionsOnce = (
1030
1039
  };
1031
1040
  let currentBlock: OpenAIStreamBlock | undefined;
1032
1041
  let messageThoughtSignature: GeminiMessageThoughtSignature | undefined;
1042
+ // Content blocks are append-only for the lifetime of the stream, so each
1043
+ // block's index is stable once pushed. Map block → index to keep the
1044
+ // per-delta contentIndex lookup O(1): a linear `indexOf` per delta turns a
1045
+ // long turn (many blocks × many deltas) quadratic, as openai-shared's
1046
+ // Responses decoder documents (issue #10605).
1047
+ const contentIndexByBlock = new Map<OpenAIStreamBlock, number>();
1048
+ const pushContentBlock = (block: OpenAIStreamBlock): void => {
1049
+ contentIndexByBlock.set(block, output.content.length);
1050
+ output.content.push(block);
1051
+ };
1033
1052
  const blockIndex = (block: OpenAIStreamBlock | undefined): number => {
1034
1053
  if (!block) return Math.max(0, output.content.length - 1);
1035
- return output.content.indexOf(block);
1054
+ return contentIndexByBlock.get(block) ?? output.content.indexOf(block);
1036
1055
  };
1037
1056
  const finishToolCallBlock = (block: ToolCallStreamBlock): void => {
1038
1057
  if (block.partialArgs === undefined) return;
@@ -1087,11 +1106,7 @@ const streamOpenAICompletionsOnce = (
1087
1106
  if (currentBlock?.type !== "toolCall") finishCurrentBlock(currentBlock);
1088
1107
  finishPendingToolCallBlocks();
1089
1108
  };
1090
- const appendText = (
1091
- message: AssistantMessage,
1092
- eventStream: AssistantMessageEventStream,
1093
- text: string,
1094
- ): void => {
1109
+ const appendText = (text: string): void => {
1095
1110
  if (currentBlock?.type !== "text") {
1096
1111
  // Leave toolCall blocks pending across text transitions: chunks after
1097
1112
  // the first typically carry only `index`, so a finished (de-registered)
@@ -1099,15 +1114,15 @@ const streamOpenAICompletionsOnce = (
1099
1114
  // resume. The stream-end sweep finalizes pending calls.
1100
1115
  if (currentBlock?.type !== "toolCall") finishCurrentBlock(currentBlock);
1101
1116
  currentBlock = { type: "text", text: "" };
1102
- message.content.push(currentBlock);
1103
- eventStream.push({ type: "text_start", contentIndex: blockIndex(currentBlock), partial: message });
1117
+ pushContentBlock(currentBlock);
1118
+ stream.push({ type: "text_start", contentIndex: blockIndex(currentBlock), partial: output });
1104
1119
  }
1105
1120
  currentBlock.text += text;
1106
- eventStream.push({
1121
+ stream.push({
1107
1122
  type: "text_delta",
1108
1123
  contentIndex: blockIndex(currentBlock),
1109
1124
  delta: text,
1110
- partial: message,
1125
+ partial: output,
1111
1126
  });
1112
1127
  };
1113
1128
  const openThinkingBlock = (signature?: string): ThinkingContent => {
@@ -1116,7 +1131,7 @@ const streamOpenAICompletionsOnce = (
1116
1131
  if (currentBlock?.type !== "toolCall") finishCurrentBlock(currentBlock);
1117
1132
  const block: ThinkingContent = { type: "thinking", thinking: "", thinkingSignature: signature };
1118
1133
  currentBlock = block;
1119
- output.content.push(block);
1134
+ pushContentBlock(block);
1120
1135
  stream.push({ type: "thinking_start", contentIndex: blockIndex(block), partial: output });
1121
1136
  return block;
1122
1137
  };
@@ -1177,7 +1192,7 @@ const streamOpenAICompletionsOnce = (
1177
1192
  const appendTextDelta = (text: string): void => {
1178
1193
  if (!text) return;
1179
1194
  if (!firstTokenTime) firstTokenTime = performance.now();
1180
- appendText(output, stream, text);
1195
+ appendText(text);
1181
1196
  };
1182
1197
  // Tracks the last full cumulative reasoning snapshot per signature (the
1183
1198
  // reasoning field name) so dedup survives block transitions. Required
@@ -1249,7 +1264,7 @@ const streamOpenAICompletionsOnce = (
1249
1264
  };
1250
1265
  block.arguments = parseStreamingJson(call.arguments);
1251
1266
  currentBlock = block;
1252
- output.content.push(block);
1267
+ pushContentBlock(block);
1253
1268
  stream.push({ type: "toolcall_start", contentIndex: blockIndex(block), partial: output });
1254
1269
  stream.push({
1255
1270
  type: "toolcall_delta",
@@ -1473,7 +1488,7 @@ const streamOpenAICompletionsOnce = (
1473
1488
  if (streamIndex !== undefined) toolCallBlockByIndex.set(streamIndex, block);
1474
1489
  pendingToolCallBlocks.push(block);
1475
1490
  currentBlock = block;
1476
- output.content.push(block);
1491
+ pushContentBlock(block);
1477
1492
  stream.push({
1478
1493
  type: "toolcall_start",
1479
1494
  contentIndex: blockIndex(block),
@@ -1,5 +1,5 @@
1
1
  import { scheduler } from "node:timers/promises";
2
- import { $flag, logger, structuredCloneJSON } from "@oh-my-pi/pi-utils";
2
+ import { $flag, logger, type ServerSentEvent, structuredCloneJSON } from "@oh-my-pi/pi-utils";
3
3
  import * as AIError from "../error";
4
4
  import { getEnvApiKey } from "../stream";
5
5
  import type {
@@ -9,7 +9,6 @@ import type {
9
9
  Model,
10
10
  OpenAICompat,
11
11
  ProviderSessionState,
12
- RawSseEvent,
13
12
  ServiceTier,
14
13
  StreamFunction,
15
14
  StreamOptions,
@@ -455,7 +454,7 @@ const streamOpenAIResponsesOnce = (
455
454
  const { requestAbortController, requestSignal } = abortTracker;
456
455
  const onSseEvent = options?.onSseEvent;
457
456
  const rawSseObserver = onSseEvent
458
- ? (event: RawSseEvent) => {
457
+ ? (event: ServerSentEvent) => {
459
458
  if (!event.event && event.data && event.data !== "[DONE]") {
460
459
  try {
461
460
  const parsed = JSON.parse(event.data);
@@ -471,7 +470,7 @@ const streamOpenAIResponsesOnce = (
471
470
  }
472
471
  } catch {}
473
472
  }
474
- onSseEvent(event, model);
473
+ onSseEvent({ event: event.event, data: event.data, raw: [...event.raw] }, model);
475
474
  }
476
475
  : undefined;
477
476