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
This commit is contained in:
Armin Ronacher 2026-10-12 10:03:29 +02:00
parent a447ba11f3
commit f11e50f72e
4 changed files with 57 additions and 12 deletions

View file

@ -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

View file

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

View file

@ -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"],

View file

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