From a5c60fa8cb442de81b99ccc4bcac8cd79e8c044f Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 11 Nov 2026 23:40:11 -0500 Subject: [PATCH 1/2] fix(sequentialthinking): count header rows toward the log box's row cap A long branchId wraps onto header rows that MAX_BOX_ROWS did not count, so it could draw past the cap #5433 set for the thought's rows. The header's rows now count toward the cap, and a header too long for it is cut, its last row saying how many characters it left out. Closes #5526 Signed-off-by: cliffhall Co-Authored-By: Claude Opus 5.5 --- .../sequentialthinking-header-row-cap.md | 5 ++ src/sequentialthinking/README.md | 2 +- .../__tests__/thought-logging.test.ts | 83 ++++++++++++++++--- src/sequentialthinking/lib.ts | 40 ++++++--- 4 files changed, 107 insertions(+), 23 deletions(-) create mode 100644 .changeset/sequentialthinking-header-row-cap.md diff --git a/.changeset/sequentialthinking-header-row-cap.md b/.changeset/sequentialthinking-header-row-cap.md new file mode 100644 index 0000000000..53ca6f1233 --- /dev/null +++ b/.changeset/sequentialthinking-header-row-cap.md @@ -0,0 +1,5 @@ +--- +"@modelcontextprotocol/server-sequential-thinking": patch +--- + +Count the thought box's header rows toward its 1000-row cap, so a long `branchId` can no longer draw past it on stderr, and cut a header too long for the cap with a row saying how many characters it left out (#5526). diff --git a/src/sequentialthinking/README.md b/src/sequentialthinking/README.md index 81ae0ae26a..8cc582d53d 100644 --- a/src/sequentialthinking/README.md +++ b/src/sequentialthinking/README.md @@ -134,7 +134,7 @@ On Windows, use `cmd /c` to launch `npx`: To disable logging of thought information set env var: `DISABLE_THOUGHT_LOGGING` to `true`. -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 (and a long branch ID in the header) wrapped, and draws at most 1000 rows of thought text; past that it ends with a row saying how many lines it left out. Only the log is shortened: the server keeps the whole thought in its history, and the tool result is unchanged. +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 (and a long branch ID in the header) wrapped, and draws at most 1000 rows of text, header and thought together; past that it ends with a row saying how many lines it left out. A header too long for those rows (a very long branch ID) is cut, with a row saying how many characters it left out. Only the log is shortened: the server keeps the whole thought in its history, and the tool result is unchanged. 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`. diff --git a/src/sequentialthinking/__tests__/thought-logging.test.ts b/src/sequentialthinking/__tests__/thought-logging.test.ts index 6b9345e295..3eac55b3c3 100644 --- a/src/sequentialthinking/__tests__/thought-logging.test.ts +++ b/src/sequentialthinking/__tests__/thought-logging.test.ts @@ -2,7 +2,7 @@ // "true" (any case), each accepted thought is drawn as a box on stderr, with // a header that differs for plain thoughts, revisions and branches, and one // row per line of the thought, wrapped at MAX_BOX_TEXT_WIDTH and cut off -// after MAX_BOX_ROWS. Thoughts +// once header and thought fill MAX_BOX_ROWS rows. Thoughts // are driven through the in-process client and stderr is captured by spying // on console.error, which also keeps the boxes off the test output. chalk's // colour level depends on the terminal the tests run in, so it is pinned to 0 @@ -232,15 +232,18 @@ ${rows.join("\n")} }); // #5433: past MAX_BOX_ROWS the box stops drawing and says how many lines - // it left out; the call itself is unaffected. + // it left out; the call itself is unaffected. The header's one row counts + // toward the cap (#5526), so the thought gets the rest. it("draws a thought with very many lines", async () => { const lines = Array.from({ length: 200_000 }, (_, i) => `${i % 10}`); const result = await conn!.think(thought({ thought: lines.join("\n") })); expect(result.isError).toBeFalsy(); const out = logged()[0].split("\n"); - expect(out).toHaveLength(MAX_BOX_ROWS + 1 + 5); + // A leading blank, the borders and separator, MAX_BOX_ROWS rows of + // header and thought, and the summary row. + expect(out).toHaveLength(MAX_BOX_ROWS + 5); expect(out.at(-2)).toContain( - `… ${200_000 - MAX_BOX_ROWS} more lines not shown`, + `… ${200_000 - (MAX_BOX_ROWS - 1)} more lines not shown`, ); // The summary is wider than every one-character line and the header, // so it sets the box's width; it must not stick out of it. @@ -284,28 +287,33 @@ ${row("short")} const result = await conn!.think(thought({ thought: text })); expect(result.isError).toBeFalsy(); const out = logged()[0]; - // 200 wrapped rows for the long line, short lines up to the row cap, - // then the summary row; each row is W + 6 characters and a newline. - const shortShown = MAX_BOX_ROWS - n / W; - expect(out.split("\n")).toHaveLength(MAX_BOX_ROWS + 1 + 5); + // The header row, 200 wrapped rows for the long line, short lines up to + // the row cap, then the summary row; each row is W + 6 characters and a + // newline. + const shortShown = MAX_BOX_ROWS - 1 - n / W; + expect(out.split("\n")).toHaveLength(MAX_BOX_ROWS + 5); expect(out).toContain(`… ${n - 1 - shortShown} more lines not shown`); expect(out.length).toBeLessThan((MAX_BOX_ROWS + 10) * (W + 7)); }); it("counts a line the row cap cuts off as not shown", async () => { - const text = `${"s\n".repeat(MAX_BOX_ROWS - 1)}${"L".repeat(2 * W)}`; + // The header row and these short lines leave one row for the long one. + const text = `${"s\n".repeat(MAX_BOX_ROWS - 2)}${"L".repeat(2 * W)}`; await conn!.think(thought({ thought: text })); const rows = logged()[0].split("\n"); - expect(rows).toHaveLength(MAX_BOX_ROWS + 1 + 5); + expect(rows).toHaveLength(MAX_BOX_ROWS + 5); expect(rows.at(-3)).toBe(row("L".repeat(W))); expect(rows.at(-2)).toBe(row("… 1 more line not shown")); }); it("draws a thought that exactly fills the cap with no summary row", async () => { - const text = Array.from({ length: MAX_BOX_ROWS }, () => "s").join("\n"); + // With the header's one row, these lines fill the cap exactly. + const text = Array.from({ length: MAX_BOX_ROWS - 1 }, () => "s").join( + "\n", + ); await conn!.think(thought({ thought: text })); const rows = logged()[0].split("\n"); - expect(rows).toHaveLength(MAX_BOX_ROWS + 5); + expect(rows).toHaveLength(MAX_BOX_ROWS + 4); expect(logged()[0]).not.toContain("not shown"); }); @@ -338,6 +346,57 @@ ${row("short")} expect(out.length).toBeLessThan((lines + 10) * (W + 7)); }); + // #5526: a long branchId's header rows count toward MAX_BOX_ROWS, so they + // cannot add rows past the cap the thought's rows are held to. + it("counts a wrapped header's rows toward the row cap", async () => { + const branchId = "b".repeat(2 * W); + const header = `🌿 Branch 1/3 (from thought 1, ID: ${branchId})`; + const headerRows = Math.ceil(header.length / W); + expect(headerRows).toBeGreaterThan(1); + await conn!.think( + thought({ + thought: Array.from({ length: MAX_BOX_ROWS }, () => "s").join("\n"), + branchFromThought: 1, + branchId, + }), + ); + const rows = logged()[0].split("\n"); + expect(rows).toHaveLength(MAX_BOX_ROWS + 5); + expect(rows.at(-2)).toBe(row(`… ${headerRows} more lines not shown`)); + }); + + // #5526: a header too long for every row is cut, so a huge branchId logs + // no more than the cap; its last row says how much was left out. + it("cuts a header longer than the row cap", async () => { + const branchId = "b".repeat(MAX_BOX_ROWS * W); + const header = `🌿 Branch 1/3 (from thought 1, ID: ${branchId})`; + const result = await conn!.think( + thought({ thought: "t", branchFromThought: 1, branchId }), + ); + expect(result.isError).toBeFalsy(); + const out = logged()[0]; + const rows = out.split("\n"); + // A leading blank, the top border, MAX_BOX_ROWS header rows, the + // separator, the thought's summary row and the bottom border. + expect(rows).toHaveLength(MAX_BOX_ROWS + 5); + const shown = (MAX_BOX_ROWS - 1) * W; + expect( + rows + .slice(2, MAX_BOX_ROWS + 1) + .map((r) => r.slice(2, 2 + W)) + .join(""), + ).toBe(header.slice(0, shown)); + expect(rows[MAX_BOX_ROWS + 1]).toBe( + row(`… ${header.length - shown} more characters not shown`), + ); + expect(rows[MAX_BOX_ROWS + 2]).toBe(`├${border}┤`); + expect(rows.at(-2)).toBe(row("… 1 more line not shown")); + expect(new Set(rows.slice(1).map((r) => r.length))).toEqual( + new Set([W + 6]), + ); + expect(out.length).toBeLessThan((MAX_BOX_ROWS + 10) * (W + 7)); + }); + it("never splits an emoji across rows", async () => { // The emoji's two UTF-16 halves would straddle the cut at W. const text = `${"a".repeat(W - 1)}😀tail`; diff --git a/src/sequentialthinking/lib.ts b/src/sequentialthinking/lib.ts index b9299910bc..8ea6e1f3e2 100644 --- a/src/sequentialthinking/lib.ts +++ b/src/sequentialthinking/lib.ts @@ -22,12 +22,15 @@ export interface ThoughtData { export const MAX_BOX_TEXT_WIDTH = 100; /** - * The most rows of thought text a box draws (#5433). Each row is padded to - * the box's width, so a thought of many short lines would still log about a - * hundred times its own size; past this many rows the box ends with one row - * saying how many lines it left out. Header rows (a long branchId) are not - * counted. Only stderr is cut: the thought kept in thoughtHistory is whole, - * and the tool result, which carries no thought text, is unchanged. + * The most rows of text a box draws, header and thought together (#5433, + * #5526). Each row is padded to the box's width, so a thought of many short + * lines would still log about a hundred times its own size; past this many + * rows the box ends with one row saying how many lines it left out. The + * header's rows count too, so a long branchId, which wraps onto rows of its + * own, cannot draw past the cap: a header too long for every row is cut, its + * last row saying how many characters it left out. Only stderr is cut: the + * thought kept in thoughtHistory is whole, and the tool result, which carries + * no thought text, is unchanged. */ export const MAX_BOX_ROWS = 1000; @@ -158,15 +161,32 @@ export class SequentialThinkingServer { ); // A header longer than the cap (a long branchId) wraps like a thought // line; only its first row, which starts with the label, is coloured. - const headerTexts = wrapRow(visibleHeader, wrapWidth); - // Rows are drawn up to MAX_BOX_ROWS; the lines left over are counted, not - // drawn, so a huge thought costs no more than the cap. + // #5526: its rows count toward MAX_BOX_ROWS. Only one character more than + // the cap's rows can hold is wrapped, so a huge branchId costs no more + // than the cap, and more than MAX_BOX_ROWS rows means the header does not + // fit. It then keeps MAX_BOX_ROWS - 1 rows and a last one saying how many + // characters it left out: always more than one row's worth. Such a header + // is longer than the box is wide, so wrapWidth is MAX_BOX_TEXT_WIDTH and + // that row fits it. + const headerTexts = wrapRow( + visibleHeader.slice(0, MAX_BOX_ROWS * wrapWidth + 1), + wrapWidth, + ); + if (headerTexts.length > MAX_BOX_ROWS) { + headerTexts.length = MAX_BOX_ROWS - 1; + const shown = headerTexts.reduce((n, text) => n + text.length, 0); + headerTexts.push( + `… ${visibleHeader.length - shown} more characters not shown`, + ); + } + // Thought rows fill what the header left of MAX_BOX_ROWS; the lines left + // over are counted, not drawn, so a huge thought costs no more than the cap. const texts: string[] = []; // Lines drawn in full; a line cut off by the cap counts as hidden. let whole = 0; for (const line of lines) { const wrapped = wrapRow(line, wrapWidth); - const room = MAX_BOX_ROWS - texts.length; + const room = MAX_BOX_ROWS - headerTexts.length - texts.length; texts.push(...wrapped.slice(0, room)); if (wrapped.length > room) break; whole++; From 1fde546c6d63a7867756c5a8ca9adc13df30d6af Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 11 Nov 2026 23:54:11 -0500 Subject: [PATCH 2/2] fix(sequentialthinking): escape line endings in a branchId's header A line ending in a branchId started a line of stderr output that the row cap never counted, so 2,000 newlines still drew about 2,000 lines. The header now shows each as \r or \n, keeping it on rows the box draws and MAX_BOX_ROWS counts. From Copilot's first review of #5535. Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- .../sequentialthinking-header-row-cap.md | 2 +- src/sequentialthinking/README.md | 2 +- .../__tests__/thought-logging.test.ts | 24 +++++++++++++++++++ src/sequentialthinking/lib.ts | 7 +++++- 4 files changed, 32 insertions(+), 3 deletions(-) diff --git a/.changeset/sequentialthinking-header-row-cap.md b/.changeset/sequentialthinking-header-row-cap.md index 53ca6f1233..a753cedf67 100644 --- a/.changeset/sequentialthinking-header-row-cap.md +++ b/.changeset/sequentialthinking-header-row-cap.md @@ -2,4 +2,4 @@ "@modelcontextprotocol/server-sequential-thinking": patch --- -Count the thought box's header rows toward its 1000-row cap, so a long `branchId` can no longer draw past it on stderr, and cut a header too long for the cap with a row saying how many characters it left out (#5526). +Count the thought box's header rows toward its 1000-row cap, so a long `branchId` can no longer draw past it on stderr: line breaks in a `branchId` are shown as `\r` and `\n`, and a header too long for the cap is cut with a row saying how many characters it left out (#5526). diff --git a/src/sequentialthinking/README.md b/src/sequentialthinking/README.md index 8cc582d53d..ea3563224f 100644 --- a/src/sequentialthinking/README.md +++ b/src/sequentialthinking/README.md @@ -134,7 +134,7 @@ On Windows, use `cmd /c` to launch `npx`: To disable logging of thought information set env var: `DISABLE_THOUGHT_LOGGING` to `true`. -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 (and a long branch ID in the header) wrapped, and draws at most 1000 rows of text, header and thought together; past that it ends with a row saying how many lines it left out. A header too long for those rows (a very long branch ID) is cut, with a row saying how many characters it left out. Only the log is shortened: the server keeps the whole thought in its history, and the tool result is unchanged. +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 (and a long branch ID in the header) wrapped, and draws at most 1000 rows of text, header and thought together; past that it ends with a row saying how many lines it left out. A line break in a branch ID is shown in the header as `\r` or `\n`, and a header too long for those rows (a very long branch ID) is cut, with a row saying how many characters it left out. Only the log is shortened: the server keeps the whole thought in its history, and the tool result is unchanged. 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`. diff --git a/src/sequentialthinking/__tests__/thought-logging.test.ts b/src/sequentialthinking/__tests__/thought-logging.test.ts index 3eac55b3c3..dda69ba097 100644 --- a/src/sequentialthinking/__tests__/thought-logging.test.ts +++ b/src/sequentialthinking/__tests__/thought-logging.test.ts @@ -365,6 +365,30 @@ ${row("short")} expect(rows.at(-2)).toBe(row(`… ${headerRows} more lines not shown`)); }); + // #5526: a line ending in a branchId would start a line of output the + // row cap never counted; the header shows it escaped instead. + it("escapes line endings in a branchId so the header stays on its rows", async () => { + const branchId = "x\r\ny\rz\n".repeat(500); + const escaped = "x\\r\\ny\\rz\\n".repeat(500); + const header = `🌿 Branch 1/3 (from thought 1, ID: ${escaped})`; + await conn!.think( + thought({ thought: "t", branchFromThought: 1, branchId }), + ); + const out = logged()[0]; + expect(out).not.toContain("\r"); + const rows = out.split("\n"); + const headerRows = Math.ceil(header.length / W); + // A leading blank, the borders and separator, the header rows and the + // thought's one row. + expect(rows).toHaveLength(headerRows + 5); + expect( + rows + .slice(2, 2 + headerRows) + .map((r) => r.slice(2, 2 + W).trimEnd()) + .join(""), + ).toBe(header); + }); + // #5526: a header too long for every row is cut, so a huge branchId logs // no more than the cap; its last row says how much was left out. it("cuts a header longer than the row cap", async () => { diff --git a/src/sequentialthinking/lib.ts b/src/sequentialthinking/lib.ts index 8ea6e1f3e2..cc13c50840 100644 --- a/src/sequentialthinking/lib.ts +++ b/src/sequentialthinking/lib.ts @@ -133,9 +133,14 @@ export class SequentialThinkingServer { } else if (branchFromThought) { label = "🌿 Branch"; colour = chalk.green; + // #5526: a line ending in the branchId is shown as its escape, \r or + // \n, so the header stays on rows the box draws and MAX_BOX_ROWS counts. context = branchId !== undefined - ? ` (from thought ${branchFromThought}, ID: ${branchId})` + ? ` (from thought ${branchFromThought}, ID: ${branchId.replace( + /[\r\n]/g, + (eol) => (eol === "\r" ? "\\r" : "\\n"), + )})` : ` (from thought ${branchFromThought})`; } else { label = "💭 Thought";