Skip to content

Commit 8da06bc

Browse files
cliffhallclaude
andcommitted
fix(sequentialthinking): bound the stderr thought box
#5205 padded every row of the box to the thought's longest line, so one long line and many short ones logged their product: a 60 KB thought wrote 400 million characters. The box is now at most MAX_BOX_TEXT_WIDTH (100) wide, wrapping longer lines and a longer header without splitting a surrogate pair, and at most MAX_BOX_ROWS (1000) rows, ending with a row that counts the lines left out. The box tops out near 107 KB; the stored thought and the tool result are unchanged. Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: cliffhall <[email protected]>
1 parent cc60278 commit 8da06bc

4 files changed

Lines changed: 198 additions & 15 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@modelcontextprotocol/server-sequential-thinking": patch
3+
---
4+
5+
Bound the thought box logged to stderr. Since the multi-line box, every row was padded to the thought's longest line, so a thought with one long line and many short ones logged their product: a 60 KB thought wrote 400 million characters and could exhaust the server's memory. The box is now at most 100 characters wide (longer lines wrap) and at most 1000 rows tall (it then says how many lines it left out), about 107 KB at most. The stored thought and the tool result are unchanged.

‎src/sequentialthinking/README.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,8 @@ On Windows, use `cmd /c` to launch `npx`:
134134

135135
To disable logging of thought information set env var: `DISABLE_THOUGHT_LOGGING` to `true`.
136136

137+
Each thought is logged to stderr as a box, one row per line of the thought. The box is at most 100 characters wide, with longer lines wrapped, and draws at most 1000 rows; past that it ends with a row saying how many lines it left out. Only the log is shortened: the thought itself is stored and returned whole.
138+
137139
To bound memory use, the server retains at most `MAX_THOUGHT_HISTORY` thoughts per connection (a positive integer; default `1000`). Past the cap the oldest thought is dropped, from its branch too, so `thoughtHistoryLength` in the result stops at the cap and a branch whose thoughts have all been dropped is no longer listed in `branches`.
138140

139141
### Usage with VS Code

‎src/sequentialthinking/__tests__/thought-logging.test.ts‎

Lines changed: 112 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
// The server's other observable output: unless DISABLE_THOUGHT_LOGGING is
22
// "true" (any case), each accepted thought is drawn as a box on stderr, with
33
// a header that differs for plain thoughts, revisions and branches, and one
4-
// row per line of the thought. Thoughts
4+
// row per line of the thought, wrapped at MAX_BOX_TEXT_WIDTH and cut off
5+
// after MAX_BOX_ROWS. Thoughts
56
// are driven through the in-process client and stderr is captured by spying
67
// on console.error, which also keeps the boxes off the test output. chalk's
78
// colour level depends on the terminal the tests run in, so it is pinned to 0
@@ -18,6 +19,7 @@ import {
1819
type MockInstance,
1920
} from "vitest";
2021
import chalk from "chalk";
22+
import { MAX_BOX_ROWS, MAX_BOX_TEXT_WIDTH } from "../lib.js";
2123
import { connect, thought, type Connected } from "./helpers.js";
2224

2325
describe("sequentialthinking thought logging", () => {
@@ -229,11 +231,119 @@ ${rows.join("\n")}
229231
expect(new Set(rows.map((row) => row.length)).size).toBe(1);
230232
});
231233

234+
// #5433: past MAX_BOX_ROWS the box stops drawing and says how many lines
235+
// it left out; the call itself is unaffected.
232236
it("draws a thought with very many lines", async () => {
233237
const lines = Array.from({ length: 200_000 }, (_, i) => `${i % 10}`);
234238
const result = await conn!.think(thought({ thought: lines.join("\n") }));
235239
expect(result.isError).toBeFalsy();
236-
expect(logged()[0].split("\n")).toHaveLength(200_000 + 5);
240+
const out = logged()[0].split("\n");
241+
expect(out).toHaveLength(MAX_BOX_ROWS + 1 + 5);
242+
expect(out.at(-2)).toContain(
243+
`… ${200_000 - MAX_BOX_ROWS} more lines not shown`,
244+
);
245+
});
246+
});
247+
248+
// #5433: the box is never wider than MAX_BOX_TEXT_WIDTH. A longer line, or
249+
// a longer header, wraps onto rows of that width, so the box grows linearly
250+
// with the thought instead of as its longest line times its line count.
251+
describe("a thought wider than the box", () => {
252+
const W = MAX_BOX_TEXT_WIDTH;
253+
const border = "─".repeat(W + 4);
254+
const row = (text: string) => `│ ${text.padEnd(border.length - 2)} │`;
255+
256+
beforeEach(async () => {
257+
conn = await connect({ disableThoughtLogging: undefined });
258+
});
259+
260+
it("wraps a long line onto rows of the capped width", async () => {
261+
const text = `${"a".repeat(W)}${"b".repeat(W)}${"c".repeat(5)}`;
262+
await conn!.think(thought({ thought: `${text}\nshort` }));
263+
expect(logged()).toEqual([
264+
`
265+
┌${border}┐
266+
${row("💭 Thought 1/3")}
267+
├${border}┤
268+
${row("a".repeat(W))}
269+
${row("b".repeat(W))}
270+
${row("c".repeat(5))}
271+
${row("short")}
272+
└${border}┘`,
273+
]);
274+
});
275+
276+
it("stays bounded for one long line and many short ones", async () => {
277+
// The case from the review of #5430: before the cap, every one of the
278+
// 19,999 short rows was padded to 20,000 characters, 400 million in all.
279+
const n = 20_000;
280+
const text = `${"x".repeat(n)}${"\ny".repeat(n - 1)}`;
281+
const result = await conn!.think(thought({ thought: text }));
282+
expect(result.isError).toBeFalsy();
283+
const out = logged()[0];
284+
// 200 wrapped rows for the long line, short lines up to the row cap,
285+
// then the summary row; each row is W + 6 characters and a newline.
286+
const shortShown = MAX_BOX_ROWS - n / W;
287+
expect(out.split("\n")).toHaveLength(MAX_BOX_ROWS + 1 + 5);
288+
expect(out).toContain(`… ${n - 1 - shortShown} more lines not shown`);
289+
expect(out.length).toBeLessThan((MAX_BOX_ROWS + 10) * (W + 7));
290+
});
291+
292+
it("counts a line the row cap cuts off as not shown", async () => {
293+
const text = `${"s\n".repeat(MAX_BOX_ROWS - 1)}${"L".repeat(2 * W)}`;
294+
await conn!.think(thought({ thought: text }));
295+
const rows = logged()[0].split("\n");
296+
expect(rows).toHaveLength(MAX_BOX_ROWS + 1 + 5);
297+
expect(rows.at(-3)).toBe(row("L".repeat(W)));
298+
expect(rows.at(-2)).toBe(row("… 1 more line not shown"));
299+
});
300+
301+
it("draws a thought that exactly fills the cap with no summary row", async () => {
302+
const text = Array.from({ length: MAX_BOX_ROWS }, () => "s").join("\n");
303+
await conn!.think(thought({ thought: text }));
304+
const rows = logged()[0].split("\n");
305+
expect(rows).toHaveLength(MAX_BOX_ROWS + 5);
306+
expect(logged()[0]).not.toContain("not shown");
307+
});
308+
309+
it("draws every row the same width", async () => {
310+
await conn!.think(thought({ thought: `${"z".repeat(3 * W + 7)}\nend` }));
311+
const rows = logged()[0].split("\n").slice(1);
312+
expect(new Set(rows.map((r) => r.length))).toEqual(new Set([W + 6]));
313+
});
314+
315+
it("wraps a header longer than the cap, and keeps the box linear", async () => {
316+
const branchId = "b".repeat(2 * W);
317+
const lines = 1_000;
318+
await conn!.think(
319+
thought({
320+
thought: Array.from({ length: lines }, () => "s").join("\n"),
321+
branchFromThought: 1,
322+
branchId,
323+
}),
324+
);
325+
const out = logged()[0];
326+
const rows = out.split("\n").slice(1);
327+
expect(new Set(rows.map((r) => r.length))).toEqual(new Set([W + 6]));
328+
const header = `🌿 Branch 1/3 (from thought 1, ID: ${branchId})`;
329+
const headerRows = rows.slice(1, rows.indexOf(`├${border}┤`));
330+
expect(headerRows.map((r) => r.slice(2, -2).trimEnd()).join("")).toBe(
331+
header,
332+
);
333+
// Each row is W + 6 characters and a newline. Uncapped, the header would
334+
// have set every row's width to its own length of more than 2 × W.
335+
expect(out.length).toBeLessThan((lines + 10) * (W + 7));
336+
});
337+
338+
it("never splits an emoji across rows", async () => {
339+
// The emoji's two UTF-16 halves would straddle the cut at W.
340+
const text = `${"a".repeat(W - 1)}😀tail`;
341+
await conn!.think(thought({ thought: text }));
342+
const rows = logged()[0].split("\n").slice(4, -1);
343+
expect(rows.map((r) => r.slice(2, -2).trimEnd())).toEqual([
344+
"a".repeat(W - 1),
345+
"😀tail",
346+
]);
237347
});
238348
});
239349

‎src/sequentialthinking/lib.ts‎

Lines changed: 79 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,49 @@ export interface ThoughtData {
1212
nextThoughtNeeded: boolean;
1313
}
1414

15+
/**
16+
* The widest the text inside a thought box gets (#5433). A longer line, or a
17+
* longer header, wraps onto further rows of this width. Without the cap every
18+
* row was padded to the longest line, so one long line and many short ones
19+
* made the box grow as their product: a 60 KB thought logged 400 million
20+
* characters. Capped, the box grows linearly with the thought.
21+
*/
22+
export const MAX_BOX_TEXT_WIDTH = 100;
23+
24+
/**
25+
* The most rows of thought text a box draws (#5433). Each row is padded to
26+
* the box's width, so a thought of many short lines would still log about a
27+
* hundred times its own size; past this many rows the box ends with one row
28+
* saying how many lines it left out. Only stderr is cut: the stored thought
29+
* and the tool result are whole.
30+
*/
31+
export const MAX_BOX_ROWS = 1000;
32+
33+
/**
34+
* `text` cut into rows of at most `width` UTF-16 code units, never between
35+
* the two halves of a surrogate pair, so an emoji is never split across rows.
36+
* An empty string is one empty row.
37+
*/
38+
function wrapRow(text: string, width: number): string[] {
39+
const rows: string[] = [];
40+
let start = 0;
41+
do {
42+
let end = Math.min(start + width, text.length);
43+
const last = text.charCodeAt(end - 1);
44+
if (
45+
end < text.length &&
46+
end - start > 1 &&
47+
last >= 0xd800 &&
48+
last <= 0xdbff
49+
) {
50+
end -= 1;
51+
}
52+
rows.push(text.slice(start, end));
53+
start = end;
54+
} while (start < text.length);
55+
return rows;
56+
}
57+
1558
/**
1659
* How many thoughts one server instance retains when MAX_THOUGHT_HISTORY is
1760
* unset (#2912). A server lives as long as its client's connection, so without
@@ -104,25 +147,48 @@ export class SequentialThinkingServer {
104147
const lines = thought.split(/\r\n|\r|\n/);
105148
// A reduce rather than Math.max(...lengths): spreading one argument per
106149
// line would overflow the call stack on a thought with very many lines.
107-
const width = lines.reduce(
108-
(widest, line) => Math.max(widest, line.length),
109-
visibleHeader.length,
150+
// #5433: never wider than MAX_BOX_TEXT_WIDTH; longer lines wrap below.
151+
const width = Math.min(
152+
MAX_BOX_TEXT_WIDTH,
153+
lines.reduce(
154+
(widest, line) => Math.max(widest, line.length),
155+
visibleHeader.length,
156+
),
110157
);
111158
const border = "─".repeat(width + 4);
112-
// The header row is padded to the border's width like the thought rows, by
113-
// the visible text's length. Each label's emoji is one surrogate pair, so
114-
// its length of 2 is also its display width in a terminal.
115-
const padding = " ".repeat(border.length - 2 - visibleHeader.length);
116-
const header = `${colour(label)}${counts}${padding}`;
117-
const rows = lines
118-
.map((line) => `│ ${line.padEnd(border.length - 2)} │`)
119-
.join("\n");
159+
// Every row, header rows included, is padded to the border's width by the
160+
// visible text's length. Each label's emoji is one surrogate pair, so its
161+
// length of 2 is also its display width in a terminal. A header longer
162+
// than the cap (a long branchId) wraps like a thought line; only its first
163+
// row, which starts with the label, is coloured.
164+
const row = (text: string) => `│ ${text.padEnd(border.length - 2)} │`;
165+
const headerRows = wrapRow(visibleHeader, width).map((text, i) =>
166+
i === 0 ? row(text).replace(label, colour(label)) : row(text),
167+
);
168+
// Rows are drawn up to MAX_BOX_ROWS; the lines left over are counted, not
169+
// drawn, so a huge thought costs no more than the cap.
170+
const rows: string[] = [];
171+
// Lines drawn in full; a line cut off by the cap counts as hidden.
172+
let whole = 0;
173+
for (const line of lines) {
174+
const wrapped = wrapRow(line, width);
175+
const room = MAX_BOX_ROWS - rows.length;
176+
for (const text of wrapped.slice(0, room)) rows.push(row(text));
177+
if (wrapped.length > room) break;
178+
whole++;
179+
}
180+
const hidden = lines.length - whole;
181+
if (hidden > 0) {
182+
rows.push(
183+
row(`… ${hidden} more line${hidden === 1 ? "" : "s"} not shown`),
184+
);
185+
}
120186

121187
return `
122188
┌${border}┐
123-
│ ${header} │
189+
${headerRows.join("\n")}
124190
├${border}┤
125-
${rows}
191+
${rows.join("\n")}
126192
└${border}┘`;
127193
}
128194

0 commit comments

Comments
 (0)