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; }