pi-ui-extend 0.1.47 → 0.1.48

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.
@@ -88,7 +88,10 @@ export function renderToolBlock(entry, rule, width, colors, options = {}) {
88
88
  }
89
89
  function renderCollapsedPreviewLines(entry, body, rule, width, target, color, colors, hasLspDiagnostics, showGutter) {
90
90
  const preview = previewBodyText(body, rule.direction, rule.previewLines);
91
- const allPreviewLines = renderToolBodyLines(preview.text, width, target, color, entry.bodyStyle, colors, undefined, entry.bodyWrap, hasLspDiagnostics, entry.bodyLineStyles, entry.preserveAnsi, showGutter, { rawLineOffset: preview.rawLineOffset, bodyEndsAfterText: !preview.omittedAfter });
91
+ // bodyLineStyles is aligned to entry.expandedText (args block prefix + output), but the
92
+ // collapsed body here is the raw output only. Applying bodyLineStyles would misalign and
93
+ // incorrectly dim the first N output lines, so we do not pass it to the collapsed preview.
94
+ const allPreviewLines = renderToolBodyLines(preview.text, width, target, color, entry.bodyStyle, colors, undefined, entry.bodyWrap, hasLspDiagnostics, undefined, entry.preserveAnsi, showGutter, { rawLineOffset: preview.rawLineOffset, bodyEndsAfterText: !preview.omittedAfter });
92
95
  const overflow = preview.omittedBefore || preview.omittedAfter || allPreviewLines.length > rule.previewLines;
93
96
  if (!overflow)
94
97
  return allPreviewLines;
@@ -0,0 +1,154 @@
1
+ /**
2
+ * WORKAROUND for @earendil-works/pi-ai bug: the Codex / OpenAI Responses API
3
+ * rejects HTTP 400 `Unknown parameter: 'input[N].content'` when non-message
4
+ * items (reasoning, function_call_output) carry a spurious `content` field.
5
+ *
6
+ * Root cause is in pi-ai's `convertResponsesMessages`, which pushes replayed
7
+ * items verbatim including a stray `content` placeholder. The upstream fix is
8
+ * uncommitted in pi-mono and never shipped, so every released pi-ai version
9
+ * (incl. 0.79.10) is affected.
10
+ *
11
+ * The fix must run at the WebSocket.send layer, not at before_provider_request:
12
+ * pi's openai-codex-responses provider uses a websocket-cached transport that
13
+ * rebuilds the body into a delta AFTER the before_provider_request hook, so the
14
+ * hook never sees the bytes actually sent. This module wraps
15
+ * WebSocket.prototype.send once and strips `content` from every non-message
16
+ * input item of each `response.create` frame, on the exact bytes leaving the
17
+ * socket. The before_provider_request hook is kept as a secondary guard for the
18
+ * SSE-fallback transport (full body, no delta).
19
+ *
20
+ * Remove this whole module once an upstream pi-ai release carries the fix.
21
+ */
22
+ type ExtensionAPI = any;
23
+
24
+ type ProviderRequestEvent = {
25
+ payload?: unknown;
26
+ };
27
+
28
+ type ProviderRequestContext = {
29
+ cwd?: string;
30
+ model?: unknown;
31
+ };
32
+
33
+ installWireStripper();
34
+
35
+ /**
36
+ * Wrap WebSocket.prototype.send once. For each `response.create` frame, strip
37
+ * the offending `content` from non-message input items and forward the cleaned
38
+ * frame. Forwards the original untouched on any parse/rewrite failure so the
39
+ * transport never breaks.
40
+ */
41
+ function installWireStripper(): void {
42
+ try {
43
+ const WS = (globalThis as { WebSocket?: typeof WebSocket }).WebSocket;
44
+ if (!WS?.prototype) return;
45
+ const proto = WS.prototype as { send?: unknown };
46
+ if (typeof proto.send !== "function") return;
47
+ const original = proto.send as (data: unknown) => void;
48
+ if ((original as unknown as { __codexFixWrapped?: boolean }).__codexFixWrapped) return;
49
+
50
+ const wrapped = function (this: unknown, data: unknown): void {
51
+ let outgoing = data;
52
+ if (typeof data === "string") {
53
+ try {
54
+ const parsed = JSON.parse(data);
55
+ const result = stripContentFromWireFrame(parsed);
56
+ if (result) {
57
+ outgoing = JSON.stringify(result.frame);
58
+ }
59
+ } catch {
60
+ // Non-JSON or malformed: forward untouched.
61
+ }
62
+ }
63
+ return original.call(this, outgoing);
64
+ };
65
+ (wrapped as unknown as { __codexFixWrapped?: boolean }).__codexFixWrapped = true;
66
+ proto.send = wrapped;
67
+ } catch {
68
+ // best-effort; never break on setup
69
+ }
70
+ }
71
+
72
+ /**
73
+ * If `frame` is a Codex `response.create` carrying an `input` (or `messages`)
74
+ * array, return the cleaned frame (content stripped from every non-message
75
+ * item) plus a tally of how many items were stripped. Returns `undefined`
76
+ * when nothing matched (caller forwards the original frame untouched). The
77
+ * returned `frame` is a clean Codex payload with no extra fields.
78
+ */
79
+ export function stripContentFromWireFrame(frame: unknown): { frame: Record<string, unknown>; stripped: number } | undefined {
80
+ if (!isRecord(frame) || frame.type !== "response.create") return undefined;
81
+ const field = Array.isArray(frame.input) ? "input" : Array.isArray(frame.messages) ? "messages" : null;
82
+ if (!field) return undefined;
83
+
84
+ const list = frame[field] as unknown[];
85
+ let stripped = 0;
86
+ let changed = false;
87
+ const next = new Array(list.length);
88
+ for (let i = 0; i < list.length; i++) {
89
+ const item = list[i];
90
+ // Only items with an explicit `type` that is NOT "message" can carry a
91
+ // spurious `content`. Role-based messages (no `type`) and typed
92
+ // messages (`type:"message"`) legitimately hold content — leave them.
93
+ if (
94
+ isRecord(item) &&
95
+ typeof item.type === "string" &&
96
+ item.type !== "message" &&
97
+ Object.prototype.hasOwnProperty.call(item, "content")
98
+ ) {
99
+ const { content: _drop, ...rest } = item;
100
+ next[i] = rest;
101
+ stripped++;
102
+ changed = true;
103
+ } else {
104
+ next[i] = item;
105
+ }
106
+ }
107
+ if (!changed) return undefined;
108
+ return { frame: { ...frame, [field]: next }, stripped };
109
+ }
110
+
111
+ export default function codexReasoningFix(pi: ExtensionAPI): void {
112
+ pi.on("before_provider_request", async (event: ProviderRequestEvent, _ctx: ProviderRequestContext) => {
113
+ const result = stripReasoningContentFromPayload(event.payload);
114
+ return result === event.payload ? undefined : result;
115
+ });
116
+ }
117
+
118
+ /**
119
+ * Strip spurious `content` from non-message items in a full payload. Secondary
120
+ * guard for the SSE-fallback / non-websocket path. Same rule as the wire
121
+ * stripper; exported for unit testing.
122
+ */
123
+ export function stripReasoningContentFromPayload(payload: unknown): unknown {
124
+ if (!isRecord(payload)) return payload;
125
+ const inputList = Array.isArray(payload.input) ? payload.input : undefined;
126
+ const messagesList = Array.isArray(payload.messages) ? payload.messages : undefined;
127
+ const field = inputList ? "input" : messagesList ? "messages" : undefined;
128
+ const list = inputList ?? messagesList;
129
+ if (!field || !list) return payload;
130
+
131
+ let changed = false;
132
+ const next = new Array(list.length);
133
+ for (let i = 0; i < list.length; i++) {
134
+ const item = list[i];
135
+ if (
136
+ isRecord(item) &&
137
+ typeof item.type === "string" &&
138
+ item.type !== "message" &&
139
+ Object.prototype.hasOwnProperty.call(item, "content")
140
+ ) {
141
+ const { content: _drop, ...rest } = item;
142
+ next[i] = rest;
143
+ changed = true;
144
+ } else {
145
+ next[i] = item;
146
+ }
147
+ }
148
+ if (!changed) return payload;
149
+ return { ...payload, [field]: next };
150
+ }
151
+
152
+ function isRecord(value: unknown): value is Record<string, unknown> {
153
+ return value !== null && typeof value === "object" && !Array.isArray(value);
154
+ }
@@ -10,6 +10,7 @@ type ExtensionModule = {
10
10
  };
11
11
 
12
12
  const MODULES: Array<{ name: string; load: () => Promise<ExtensionModule> }> = [
13
+ { name: "codex-reasoning-fix", load: () => import("./codex-reasoning-fix/index") },
13
14
  { name: "coding-discipline", load: () => import("./coding-discipline/index") },
14
15
  { name: "ast-grep", load: () => import("./ast-grep/index") },
15
16
  { name: "async-subagents", load: () => import("./async-subagents/index") },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-ui-extend",
3
- "version": "0.1.47",
3
+ "version": "0.1.48",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {