From f11e50f72ef8cdbdc828ad4196956cdc4cbf891e Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Mon, 12 Oct 2026 10:03:29 +0200 Subject: [PATCH] fix(coding-agent): make codemode output joining linear joinAdjacentText checked endsWith on the growing joined string, which makes V8 flatten the concatenation on every item. Collect each text run in an array, check the separator against the last non-empty part, and join once. Same fix in the durable code mode copy. closes #10678 --- packages/coding-agent/CHANGELOG.md | 1 + .../src/extensions/codemode/execute.ts | 28 +++++++++++++++---- .../test/suite/agent-session-codemode.test.ts | 12 ++++++++ packages/durable/src/codemode/output.ts | 28 +++++++++++++++---- 4 files changed, 57 insertions(+), 12 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index a029fa70d..1baee1786 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -26,6 +26,7 @@ - Fixed a symlinked `AGENTS.md` in a git worktree nested inside its main repo causing no project context file to load ([#10681](https://github.com/earendil-works/pi/issues/10681)) - Fixed the code mode tool description claiming every failed tool call rejects; a tool that declares an output schema resolves with its value on failure too (e.g. `bash` with a nonzero exit) - Fixed runs started by `pi.sendMessage(..., { triggerTurn: true })` removing system prompt sections added in `before_agent_start` after their first tool call, which changed the prompt mid-run and broke the provider prompt cache twice per run ([#10267](https://github.com/earendil-works/pi/issues/10267)) +- Fixed codemode blocking the process for seconds to minutes after scripts with many `text()` calls; joining the output items was quadratic ([#10678](https://github.com/earendil-works/pi/issues/10678)) ## [1.1.0] - 2026-10-07 diff --git a/packages/coding-agent/src/extensions/codemode/execute.ts b/packages/coding-agent/src/extensions/codemode/execute.ts index 468b4638f..68f896a3b 100644 --- a/packages/coding-agent/src/extensions/codemode/execute.ts +++ b/packages/coding-agent/src/extensions/codemode/execute.ts @@ -285,18 +285,34 @@ function formatOutput(output: readonly CodemodeOutputItem[]): (TextContent | Ima return items; } -/** Join adjacent text items into one, each part starting on its own line. */ +/** + * Join adjacent text items into one, each part starting on its own line. The separator check looks + * at the last non-empty part, not the joined text: reading the end of a growing concatenation makes + * V8 copy it on every item, which is quadratic (#10678). + */ function joinAdjacentText(items: (TextContent | ImageContent)[]): (TextContent | ImageContent)[] { const joined: (TextContent | ImageContent)[] = []; + let parts: string[] = []; + let lastPart = ""; + const flush = () => { + if (parts.length > 0) joined.push({ type: "text", text: parts.join("") }); + parts = []; + lastPart = ""; + }; for (const item of items) { - const last = joined.at(-1); - if (item.type === "text" && last?.type === "text") { - const separator = last.text === "" || last.text.endsWith("\n") ? "" : "\n"; - joined[joined.length - 1] = { type: "text", text: `${last.text}${separator}${item.text}` }; - } else { + if (item.type !== "text") { + flush(); joined.push(item); + continue; } + if (lastPart !== "" && !lastPart.endsWith("\n")) { + parts.push("\n"); + lastPart = "\n"; + } + parts.push(item.text); + if (item.text !== "") lastPart = item.text; } + flush(); return joined; } diff --git a/packages/coding-agent/test/suite/agent-session-codemode.test.ts b/packages/coding-agent/test/suite/agent-session-codemode.test.ts index 4869645a3..4b6b53c99 100644 --- a/packages/coding-agent/test/suite/agent-session-codemode.test.ts +++ b/packages/coding-agent/test/suite/agent-session-codemode.test.ts @@ -541,6 +541,18 @@ describe("codemode options and store", () => { expect((small.details as unknown as CodemodeToolDetails).fullOutputPath).toBeUndefined(); }); + // #10678: joining the text items was quadratic, about 10 s here for the item limit. + it("formats output at the item limit in linear time", async () => { + const harness = await setup(); + const startedAt = performance.now(); + const result = await run(harness, 'for (let i = 0; i < 100000; i++) text("");'); + const elapsed = performance.now() - startedAt; + rmSync((result.details as unknown as CodemodeToolDetails).fullOutputPath ?? "", { force: true }); + expect(result.isError).toBe(false); + expect(resultText(result)).toContain("==> text 100000/100000 <=="); + expect(elapsed).toBeLessThan(5000); + }); + it("resolves bash calls to structured results, also for non-zero exit codes", async () => { const harness = await createHarness({ initialActiveToolNames: ["codemode", "bash"], diff --git a/packages/durable/src/codemode/output.ts b/packages/durable/src/codemode/output.ts index 5b364378f..98a086288 100644 --- a/packages/durable/src/codemode/output.ts +++ b/packages/durable/src/codemode/output.ts @@ -60,18 +60,34 @@ function formatItems(output: readonly CodemodeOutputItem[]): Item[] { return items; } -/** Join adjacent text items into one, each part starting on its own line. */ +/** + * Join adjacent text items into one, each part starting on its own line. The separator check looks at the last + * non-empty part, not the joined text: reading the end of a growing concatenation makes V8 copy it on every item, which + * is quadratic. + */ function joinAdjacentText(items: readonly Item[]): Item[] { const joined: Item[] = []; + let parts: string[] = []; + let lastPart = ""; + const flush = () => { + if (parts.length > 0) joined.push({ type: "text", text: parts.join("") }); + parts = []; + lastPart = ""; + }; for (const item of items) { - const last = joined.at(-1); - if (item.type === "text" && last?.type === "text") { - const separator = last.text === "" || last.text.endsWith("\n") ? "" : "\n"; - joined[joined.length - 1] = { type: "text", text: `${last.text}${separator}${item.text}` }; - } else { + if (item.type !== "text") { + flush(); joined.push(item); + continue; } + if (lastPart !== "" && !lastPart.endsWith("\n")) { + parts.push("\n"); + lastPart = "\n"; + } + parts.push(item.text); + if (item.text !== "") lastPart = item.text; } + flush(); return joined; }