@xynogen/pix-pretty 1.1.0 → 1.3.0

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,190 +1,181 @@
1
1
  /**
2
- * Tests for thinking tag rendering
2
+ * Tests for leaked-reasoning splitting into native content blocks.
3
+ *
4
+ * splitThinking() turns leaked `<think>`/`<thinking>` spans into real
5
+ * `thinking` content blocks (rendered dim + italic by pi's native
6
+ * `thinkingText` styling) while keeping surrounding answer text as `text`
7
+ * blocks.
3
8
  */
4
9
 
5
10
  import { describe, expect, it } from "bun:test";
6
- import { renderThinking, stripPartialTailTag } from "./thinking";
11
+ import { splitThinking, stripPartialTailTag } from "./thinking";
7
12
 
8
- describe("thinking tag rendering", () => {
13
+ type Block = { type: string; text?: string; thinking?: string };
14
+
15
+ function texts(blocks: Block[]): string[] {
16
+ return blocks.filter((b) => b.type === "text").map((b) => b.text ?? "");
17
+ }
18
+ function thinkings(blocks: Block[]): string[] {
19
+ return blocks
20
+ .filter((b) => b.type === "thinking")
21
+ .map((b) => b.thinking ?? "");
22
+ }
23
+
24
+ describe("splitThinking", () => {
9
25
  describe("closed thinking blocks", () => {
10
- it("renders basic <thinking> block as blockquote", () => {
11
- const input = "<thinking>This is reasoning</thinking>";
12
- const output = renderThinking(input);
13
- expect(output).toBe(`> This is reasoning\n\n`);
26
+ it("turns a <thinking> span into a thinking block", () => {
27
+ const out = splitThinking("<thinking>This is reasoning</thinking>");
28
+ expect(out).toEqual([
29
+ { type: "thinking", thinking: "This is reasoning" },
30
+ ]);
14
31
  });
15
32
 
16
- it("renders basic <think> block as blockquote", () => {
17
- const input = "<think>This is reasoning</think>";
18
- const output = renderThinking(input);
19
- expect(output).toBe(`> This is reasoning\n\n`);
33
+ it("turns a <think> span into a thinking block", () => {
34
+ const out = splitThinking("<think>This is reasoning</think>");
35
+ expect(out).toEqual([
36
+ { type: "thinking", thinking: "This is reasoning" },
37
+ ]);
20
38
  });
21
39
 
22
- it("renders multi-line thinking block as blockquote", () => {
23
- const input = "<thinking>Line 1\nLine 2\nLine 3</thinking>";
24
- const output = renderThinking(input);
25
- expect(output).toBe(`> Line 1\n> Line 2\n> Line 3\n\n`);
40
+ it("preserves multi-line reasoning inside one thinking block", () => {
41
+ const out = splitThinking("<thinking>Line 1\nLine 2\nLine 3</thinking>");
42
+ expect(out).toEqual([
43
+ { type: "thinking", thinking: "Line 1\nLine 2\nLine 3" },
44
+ ]);
26
45
  });
27
46
 
28
- it("renders multiple thinking blocks", () => {
29
- const input =
30
- "<thinking>First block</thinking> Some text <thinking>Second block</thinking>";
31
- const output = renderThinking(input);
32
- expect(output).toContain("First block");
33
- expect(output).toContain("Second block");
34
- expect(output).toContain("Some text");
35
- expect(output).toContain(">");
47
+ it("emits one thinking block per closed span in order", () => {
48
+ const out = splitThinking(
49
+ "<thinking>First block</thinking> Some text <thinking>Second block</thinking>",
50
+ );
51
+ expect(thinkings(out)).toEqual(["First block", "Second block"]);
52
+ expect(texts(out)).toEqual(["Some text"]);
36
53
  });
37
54
 
38
- it("removes empty thinking blocks", () => {
39
- const input = "Before <thinking></thinking> After";
40
- const output = renderThinking(input);
41
- expect(output).not.toContain("thinking");
42
- expect(output).toContain("Before");
43
- expect(output).toContain("After");
55
+ it("drops empty thinking spans", () => {
56
+ const out = splitThinking("Before <thinking></thinking> After");
57
+ expect(thinkings(out)).toEqual([]);
58
+ expect(texts(out).join(" ")).toContain("Before");
59
+ expect(texts(out).join(" ")).toContain("After");
44
60
  });
45
61
 
46
- it("removes thinking blocks with only whitespace", () => {
47
- const input = "Before <thinking> \n </thinking> After";
48
- const output = renderThinking(input);
49
- expect(output).not.toContain(">");
50
- expect(output).toContain("Before");
51
- expect(output).toContain("After");
62
+ it("drops whitespace-only thinking spans", () => {
63
+ const out = splitThinking("Before <thinking> \n </thinking> After");
64
+ expect(thinkings(out)).toEqual([]);
52
65
  });
53
66
 
54
67
  it("trims whitespace from thinking content", () => {
55
- const input = "<thinking>\n This is reasoning \n</thinking>";
56
- const output = renderThinking(input);
57
- expect(output).toBe(`> This is reasoning\n\n`);
68
+ const out = splitThinking(
69
+ "<thinking>\n This is reasoning \n</thinking>",
70
+ );
71
+ expect(out).toEqual([
72
+ { type: "thinking", thinking: "This is reasoning" },
73
+ ]);
58
74
  });
59
75
 
60
76
  it("handles mixed case tag names", () => {
61
- const input =
62
- "<THINKING>uppercase</THINKING> <ThInKiNg>mixedcase</ThInKiNg>";
63
- const output = renderThinking(input);
64
- expect(output).toContain("uppercase");
65
- expect(output).toContain("mixedcase");
66
- expect(output).toContain(">");
77
+ const out = splitThinking(
78
+ "<THINKING>uppercase</THINKING> <ThInKiNg>mixedcase</ThInKiNg>",
79
+ );
80
+ expect(thinkings(out)).toEqual(["uppercase", "mixedcase"]);
67
81
  });
68
82
  });
69
83
 
70
84
  describe("dangling/unclosed blocks", () => {
71
- it("renders dangling <thinking> block at end of text", () => {
72
- const input = "Some text <thinking>Reasoning without close tag";
73
- const output = renderThinking(input);
74
- expect(output).toContain("Reasoning without close tag");
75
- expect(output).toContain("Some text");
76
- expect(output).toContain(">");
85
+ it("treats a trailing <thinking> as a thinking block", () => {
86
+ const out = splitThinking("Some text <thinking>Reasoning without close");
87
+ expect(texts(out)).toEqual(["Some text"]);
88
+ expect(thinkings(out)).toEqual(["Reasoning without close"]);
77
89
  });
78
90
 
79
- it("renders dangling <think> block at end of text", () => {
80
- const input = "Some text <think>Reasoning without close tag";
81
- const output = renderThinking(input);
82
- expect(output).toContain("Reasoning without close tag");
83
- expect(output).toContain("Some text");
84
- expect(output).toContain(">");
91
+ it("treats a trailing <think> as a thinking block", () => {
92
+ const out = splitThinking("Some text <think>Reasoning without close");
93
+ expect(texts(out)).toEqual(["Some text"]);
94
+ expect(thinkings(out)).toEqual(["Reasoning without close"]);
85
95
  });
86
96
 
87
- it("does not treat mid-text unclosed tag as dangling", () => {
88
- // Only dangling blocks at the END of text are processed by OPEN_TAIL_RE
89
- const input = "<thinking>Unclosed\nMore text after";
90
- const output = renderThinking(input);
91
- expect(output).toContain(">");
97
+ it("captures the remainder of a leading unclosed tag as reasoning", () => {
98
+ const out = splitThinking("<thinking>Unclosed\nMore text after");
99
+ expect(thinkings(out)).toEqual(["Unclosed\nMore text after"]);
100
+ expect(texts(out)).toEqual([]);
92
101
  });
93
102
  });
94
103
 
95
104
  describe("orphan tags", () => {
96
- it("removes orphan closing tags", () => {
97
- const input = "Some text </thinking> more text";
98
- const output = renderThinking(input);
99
- expect(output).not.toContain("</thinking>");
100
- expect(output).not.toContain("<thinking>");
101
- expect(output).toContain("Some text");
102
- expect(output).toContain("more text");
105
+ it("removes orphan closing tags from text", () => {
106
+ const out = splitThinking("Some text </thinking> more text");
107
+ const joined = texts(out).join(" ");
108
+ expect(joined).not.toContain("</thinking>");
109
+ expect(joined).not.toContain("<thinking>");
110
+ expect(joined).toContain("Some text");
111
+ expect(joined).toContain("more text");
103
112
  });
104
113
 
105
- it("removes orphan opening tags after processing blocks", () => {
106
- const input = "Text <think> orphan tag";
107
- const output = renderThinking(input);
108
- expect(output).not.toContain("<think>");
109
- expect(output).toContain("Text");
114
+ it("treats a trailing open tag as a (possibly empty) reasoning span", () => {
115
+ const out = splitThinking("Text <think> orphan tag");
116
+ expect(texts(out)).toEqual(["Text"]);
117
+ expect(thinkings(out)).toEqual(["orphan tag"]);
110
118
  });
111
119
 
112
- it("removes multiple orphan tags", () => {
113
- const input = "</thinking> text </think> more <thinking> stuff </think>";
114
- const output = renderThinking(input);
115
- // Note: <thinking> at the end is treated as dangling block, creating a blockquote
116
- expect(output).not.toContain("<thinking>");
117
- expect(output).not.toContain("</thinking>");
118
- expect(output).not.toContain("</think>");
119
- expect(output).toContain("text");
120
- expect(output).toContain("stuff");
120
+ it("handles multiple orphan tags", () => {
121
+ const out = splitThinking(
122
+ "</thinking> text </think> more <thinking> stuff </think>",
123
+ );
124
+ const joined = texts(out).join(" ");
125
+ expect(joined).not.toContain("<thinking>");
126
+ expect(joined).not.toContain("</thinking>");
127
+ expect(joined).not.toContain("</think>");
128
+ expect(joined).toContain("text");
129
+ expect(thinkings(out).join(" ")).toContain("stuff");
121
130
  });
122
131
  });
123
132
 
124
133
  describe("text without thinking tags", () => {
125
- it("returns text unchanged when no thinking tags present", () => {
134
+ it("returns the original text block unchanged", () => {
126
135
  const input = "This is regular text without any tags";
127
- const output = renderThinking(input);
128
- expect(output).toBe(input);
136
+ expect(splitThinking(input)).toEqual([{ type: "text", text: input }]);
129
137
  });
130
138
 
131
- it("preserves markdown formatting", () => {
139
+ it("preserves markdown formatting verbatim", () => {
132
140
  const input = "# Header\n\n**bold** and *italic*";
133
- const output = renderThinking(input);
134
- expect(output).toBe(input);
135
- });
136
- });
137
-
138
- describe("mixed content", () => {
139
- it("preserves text before thinking block", () => {
140
- const input = "Response text here\n\n<thinking>reasoning</thinking>";
141
- const output = renderThinking(input);
142
- expect(output).toContain("Response text here");
143
- expect(output).toContain("reasoning");
144
- expect(output).toContain(">");
145
- });
146
-
147
- it("preserves text after thinking block", () => {
148
- const input = "<thinking>reasoning</thinking>\n\nMore response text";
149
- const output = renderThinking(input);
150
- expect(output).toContain("reasoning");
151
- expect(output).toContain("More response text");
152
- expect(output).toContain(">");
153
- });
154
-
155
- it("preserves text between multiple thinking blocks", () => {
156
- const input =
157
- "<thinking>first</thinking>\n\nMiddle text\n\n<thinking>second</thinking>";
158
- const output = renderThinking(input);
159
- expect(output).toContain("first");
160
- expect(output).toContain("Middle text");
161
- expect(output).toContain("second");
162
- expect(output).toContain(">");
141
+ expect(splitThinking(input)).toEqual([{ type: "text", text: input }]);
163
142
  });
164
143
  });
165
144
 
166
- describe("newline cleanup", () => {
167
- it("reduces excessive newlines to maximum of 3", () => {
168
- const input = "Text\n\n\n\n\n\nMore text";
169
- const output = renderThinking(input);
170
- expect(output).not.toContain("\n\n\n\n");
171
- expect(output).toBe("Text\n\n\nMore text");
145
+ describe("mixed content order", () => {
146
+ it("keeps text before a thinking block", () => {
147
+ const out = splitThinking(
148
+ "Response text here\n\n<thinking>reasoning</thinking>",
149
+ );
150
+ expect(out).toEqual([
151
+ { type: "text", text: "Response text here" },
152
+ { type: "thinking", thinking: "reasoning" },
153
+ ]);
172
154
  });
173
155
 
174
- it("removes leading whitespace", () => {
175
- const input = " \n \n Text";
176
- const output = renderThinking(input);
177
- expect(output).toBe("Text");
156
+ it("keeps text after a thinking block", () => {
157
+ const out = splitThinking(
158
+ "<thinking>reasoning</thinking>\n\nMore response text",
159
+ );
160
+ expect(out).toEqual([
161
+ { type: "thinking", thinking: "reasoning" },
162
+ { type: "text", text: "More response text" },
163
+ ]);
178
164
  });
179
165
 
180
- it("preserves necessary newlines", () => {
181
- const input = "Line 1\n\nLine 2";
182
- const output = renderThinking(input);
183
- expect(output).toBe(input);
166
+ it("keeps text between multiple thinking blocks in order", () => {
167
+ const out = splitThinking(
168
+ "<thinking>first</thinking>\n\nMiddle text\n\n<thinking>second</thinking>",
169
+ );
170
+ expect(out).toEqual([
171
+ { type: "thinking", thinking: "first" },
172
+ { type: "text", text: "Middle text" },
173
+ { type: "thinking", thinking: "second" },
174
+ ]);
184
175
  });
185
176
  });
186
177
 
187
- describe("streaming (partial tail tags + live rendering)", () => {
178
+ describe("streaming (partial tail tags)", () => {
188
179
  it("strips a half-streamed opening tag", () => {
189
180
  expect(stripPartialTailTag("Hello <thin")).toBe("Hello ");
190
181
  expect(stripPartialTailTag("Hello <")).toBe("Hello ");
@@ -205,63 +196,56 @@ describe("thinking tag rendering", () => {
205
196
  expect(stripPartialTailTag("<thinking>body")).toBe("<thinking>body");
206
197
  });
207
198
 
208
- it("renders an open block as blockquote before close tag arrives", () => {
209
- // Simulates mid-stream state: open tag + partial body, no close tag.
199
+ it("emits a thinking block for an open span before the close arrives", () => {
210
200
  const midStream = "<thinking>I am reasoning about";
211
- const output = renderThinking(stripPartialTailTag(midStream));
212
- expect(output).toBe("> I am reasoning about\n\n");
201
+ const out = splitThinking(stripPartialTailTag(midStream));
202
+ expect(out).toEqual([
203
+ { type: "thinking", thinking: "I am reasoning about" },
204
+ ]);
213
205
  });
214
206
 
215
- it("renders progressively without flashing partial close tag", () => {
216
- const step1 = renderThinking(stripPartialTailTag("<think>step one"));
217
- const step2 = renderThinking(
207
+ it("renders progressively without flashing a partial close tag", () => {
208
+ const step1 = splitThinking(stripPartialTailTag("<think>step one"));
209
+ const step2 = splitThinking(
218
210
  stripPartialTailTag("<think>step one and two</thi"),
219
211
  );
220
- const step3 = renderThinking(
212
+ const step3 = splitThinking(
221
213
  stripPartialTailTag("<think>step one and two</think>\n\nAnswer"),
222
214
  );
223
- expect(step1).toBe("> step one\n\n");
224
- expect(step2).toBe("> step one and two\n\n");
225
- expect(step3).toContain("> step one and two");
226
- expect(step3).toContain("Answer");
227
- expect(step3).not.toContain("<think>");
215
+ expect(step1).toEqual([{ type: "thinking", thinking: "step one" }]);
216
+ expect(step2).toEqual([
217
+ { type: "thinking", thinking: "step one and two" },
218
+ ]);
219
+ expect(step3).toEqual([
220
+ { type: "thinking", thinking: "step one and two" },
221
+ { type: "text", text: "Answer" },
222
+ ]);
228
223
  });
229
224
  });
230
225
 
231
226
  describe("edge cases", () => {
232
- it("handles empty string", () => {
233
- const input = "";
234
- const output = renderThinking(input);
235
- expect(output).toBe("");
236
- });
237
-
238
- it("handles string with only whitespace", () => {
239
- const input = " \n \n ";
240
- const output = renderThinking(input);
241
- expect(output).toBe("");
227
+ it("returns a single text block for an empty string", () => {
228
+ expect(splitThinking("")).toEqual([{ type: "text", text: "" }]);
242
229
  });
243
230
 
244
- it("handles nested-looking tags (not actually nested in HTML sense)", () => {
245
- const input =
246
- "<thinking>outer <thinking>inner</thinking> outer</thinking>";
247
- const output = renderThinking(input);
248
- // Regex will match first <thinking>...</thinking> pair
249
- expect(output).toContain(">");
231
+ it("collapses an all-empty reasoning message to one empty text block", () => {
232
+ expect(splitThinking("<thinking></thinking>")).toEqual([
233
+ { type: "text", text: "" },
234
+ ]);
250
235
  });
251
236
 
252
237
  it("handles thinking content with special characters", () => {
253
- const input = "<thinking>Special chars: $@#%^&*()</thinking>";
254
- const output = renderThinking(input);
255
- expect(output).toContain("Special chars: $@#%^&*()");
256
- expect(output).toContain(">");
238
+ const out = splitThinking(
239
+ "<thinking>Special chars: $@#%^&*()</thinking>",
240
+ );
241
+ expect(thinkings(out)).toEqual(["Special chars: $@#%^&*()"]);
257
242
  });
258
243
 
259
244
  it("handles thinking content with code-like syntax", () => {
260
- const input = "<thinking>const x = 5;\nreturn x + 1;</thinking>";
261
- const output = renderThinking(input);
262
- expect(output).toContain("const x = 5;");
263
- expect(output).toContain("return x + 1;");
264
- expect(output).toContain(">");
245
+ const out = splitThinking(
246
+ "<thinking>const x = 5;\nreturn x + 1;</thinking>",
247
+ );
248
+ expect(thinkings(out)).toEqual(["const x = 5;\nreturn x + 1;"]);
265
249
  });
266
250
  });
267
251
  });