diff --git a/packages/coding-agent/src/core/changes.md b/packages/coding-agent/src/core/changes.md index 68f00bb3b1..111a3ce823 100644 --- a/packages/coding-agent/src/core/changes.md +++ b/packages/coding-agent/src/core/changes.md @@ -1,5 +1,34 @@ # changes +## 2026-09-05 - Skip live-history budget on same-model resume + +### What changed + +- `createAgentSession` treats a restored session as same-persisted-model when existing + messages are present and the saved provider/modelId equals the resolved model + provider/id. That path admits with `liveContextTokens = 0` so only the fixed prompt, + tools, and reserves are validated. Different explicit targets and fallback replacements + still sum the restored transcript. Resume still omits speculation lead + (`includeSpeculationLead: false`, `admission: "resume"`). `_setModel` and prompt + pre-compaction guards are unchanged. + +### Why + +- Same-model resume of an already-saved oversized history was rejected before the TUI + opened, so the user could not compact or continue. The history was produced by that + model; startup should not re-charge it as a downswitch. Switching to a different model + must still refuse a transcript that cannot fit. + +### Why an extension could not handle it + +- `createAgentSession` throws `ModelUsabilityBudgetError` before extensions are wired; + no extension hook can intercept that constructor-time reject. + +### Expected merge conflict zones + +- LOW: the startup `assertModelUsable` call in `sdk.ts`; + `test/suite/model-usability-budget.test.ts`. + ## 2026-09-05 - Require explicit fallback chains ### What changed diff --git a/packages/coding-agent/src/core/extensions/builtin/terminal/changes.md b/packages/coding-agent/src/core/extensions/builtin/terminal/changes.md index 8fbf95bef7..3fb9398aaf 100644 --- a/packages/coding-agent/src/core/extensions/builtin/terminal/changes.md +++ b/packages/coding-agent/src/core/extensions/builtin/terminal/changes.md @@ -1,5 +1,23 @@ # terminal builtin extension — fork surface +## bash_output completed/expanded TUI wrap (2026-09-05) + +### What changed + +- `tools/render.ts`: completed and expanded `bash_output` results wrap with `wrapTextWithAnsi(..., Math.max(1, width))` instead of raw `split("\n")`. Partial preview still uses `truncateToVisualLines`. + +### Why + +- A completed or expanded result could emit 102/180-column lines into a 98-column Box, overflowing the TUI width contract. Wrapping is display-only; it is not the process-exit root cause. + +### Why an extension could not handle it + +- The overflow is in this builtin's own `renderResult` component. + +### Expected merge conflict zones + +- LOW: `tools/render.ts` completed/expanded `render()` branch and `test/suite/terminal-bash-output-width.test.ts`. + ## `persistent` reads as the standing-watch switch (2026-09-04) ### What changed diff --git a/packages/coding-agent/src/core/extensions/builtin/terminal/tools/render.ts b/packages/coding-agent/src/core/extensions/builtin/terminal/tools/render.ts index 67f7a68994..38c0ce525b 100644 --- a/packages/coding-agent/src/core/extensions/builtin/terminal/tools/render.ts +++ b/packages/coding-agent/src/core/extensions/builtin/terminal/tools/render.ts @@ -1,4 +1,4 @@ -import { Text } from "@earendil-works/pi-tui"; +import { Text, wrapTextWithAnsi } from "@earendil-works/pi-tui"; import { truncateToVisualLines } from "../../../../../modes/interactive/components/visual-truncate.ts"; import type { AgentToolResult, ToolDefinition } from "../../../types.ts"; import type { BashOutputInput, bashOutputSchema } from "./bash-output.ts"; @@ -24,7 +24,7 @@ class BashOutputResultComponent { } render(width: number): string[] { - if (!this.#isPartial || this.#expanded) return this.#text.split("\n"); + if (!this.#isPartial || this.#expanded) return wrapTextWithAnsi(this.#text, Math.max(1, width)); return truncateToVisualLines(this.#text, OUTPUT_PREVIEW_LINES, Math.max(1, width)).visualLines.map((line) => line.trimEnd(), ); diff --git a/packages/coding-agent/src/core/sdk.ts b/packages/coding-agent/src/core/sdk.ts index edd0d47315..f05f6f550e 100644 --- a/packages/coding-agent/src/core/sdk.ts +++ b/packages/coding-agent/src/core/sdk.ts @@ -516,9 +516,16 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} sessionStartEvent, autoTitleSessions: options.autoTitleSessions, }); - const liveContextTokens = hasExistingSession - ? existingSession.messages.reduce((total, message) => total + estimateTokens(message), 0) - : 0; + const samePersistedModel = + hasExistingSession && + existingSession.model != null && + model != null && + existingSession.model.provider === model.provider && + existingSession.model.modelId === model.id; + const liveContextTokens = + hasExistingSession && !samePersistedModel + ? existingSession.messages.reduce((total, message) => total + estimateTokens(message), 0) + : 0; session.assertModelUsable( undefined, liveContextTokens, diff --git a/packages/coding-agent/test/suite/model-usability-budget.test.ts b/packages/coding-agent/test/suite/model-usability-budget.test.ts index 16b908a8b5..666feba1ff 100644 --- a/packages/coding-agent/test/suite/model-usability-budget.test.ts +++ b/packages/coding-agent/test/suite/model-usability-budget.test.ts @@ -367,6 +367,157 @@ describe("model usability budget", () => { fitting.session.dispose(); }); + it("resumes a same-saved-model transcript that exceeds the startup budget", async () => { + // given + const harness = await createHarness({ + models: [{ id: "startup", contextWindow: 100_000, maxTokens: 4_000 }], + }); + harnesses.push(harness); + const model = harness.getModel(); + const sessionManager = SessionManager.inMemory(harness.tempDir); + sessionManager.appendModelChange(model.provider, model.id); + sessionManager.appendMessage({ + role: "user", + content: [{ type: "text", text: "restored transcript ".repeat(200_000) }], + timestamp: Date.now(), + }); + + // when + const resumed = await createAgentSession({ + cwd: harness.tempDir, + agentDir: join(harness.tempDir, "same-saved-agent"), + model, + sessionManager, + }); + + // then + expect(resumed.session.agent.state.messages).toEqual(sessionManager.buildSessionContext().messages); + expect(resumed.session.model?.id).toBe(model.id); + resumed.session.dispose(); + }); + + it("rejects a same-saved-model resume when the fixed prompt and reserves cannot fit", async () => { + // given + const harness = await createHarness({ + models: [{ id: "startup", contextWindow: 16_000, maxTokens: 4_000 }], + }); + harnesses.push(harness); + const model = harness.getModel(); + const sessionManager = SessionManager.inMemory(harness.tempDir); + sessionManager.appendModelChange(model.provider, model.id); + sessionManager.appendMessage({ + role: "user", + content: [{ type: "text", text: "restored transcript ".repeat(200_000) }], + timestamp: Date.now(), + }); + + // when + const error = await createAgentSession({ + cwd: harness.tempDir, + agentDir: join(harness.tempDir, "same-saved-fixed-base"), + model, + sessionManager, + }).then( + () => undefined, + (reason: unknown) => reason, + ); + + // then + expect(error).toBeInstanceOf(ModelUsabilityBudgetError); + if (!(error instanceof ModelUsabilityBudgetError)) throw new Error("expected fixed-base resume rejection"); + expect(error.projection).toMatchObject({ + model: `${model.provider}/${model.id}`, + usable: false, + liveContextTokens: 0, + admission: "resume", + speculationLeadTokens: 0, + contextWindow: 16_000, + }); + }); + + it("rejects a resumed transcript when the explicit target differs from the saved model", async () => { + // given + const harness = await createHarness({ + models: [ + { id: "saved", contextWindow: 100_000, maxTokens: 4_000 }, + { id: "other", contextWindow: 100_000, maxTokens: 4_000 }, + ], + }); + harnesses.push(harness); + const saved = harness.getModel("saved"); + const other = harness.getModel("other"); + if (!saved) throw new Error("missing saved model fixture"); + if (!other) throw new Error("missing other model fixture"); + const sessionManager = SessionManager.inMemory(harness.tempDir); + sessionManager.appendModelChange(saved.provider, saved.id); + sessionManager.appendMessage({ + role: "user", + content: [{ type: "text", text: "restored transcript ".repeat(200_000) }], + timestamp: Date.now(), + }); + + // when + const error = await createAgentSession({ + cwd: harness.tempDir, + agentDir: join(harness.tempDir, "different-target-agent"), + model: other, + sessionManager, + }).then( + () => undefined, + (reason: unknown) => reason, + ); + + // then + expect(error).toBeInstanceOf(ModelUsabilityBudgetError); + if (!(error instanceof ModelUsabilityBudgetError)) throw new Error("expected different-target resume rejection"); + expect(error.projection).toMatchObject({ + model: `${other.provider}/${other.id}`, + usable: false, + admission: "resume", + speculationLeadTokens: 0, + }); + expect(error.projection.liveContextTokens).toBeGreaterThan(0); + }); + + it("rejects a resumed transcript when fallback replaces an unrestorable saved model", async () => { + // given + const harness = await createHarness({ + models: [{ id: "startup", contextWindow: 100_000, maxTokens: 4_000 }], + }); + harnesses.push(harness); + const fallback = harness.getModel(); + const sessionManager = SessionManager.inMemory(harness.tempDir); + sessionManager.appendModelChange("gone", "gone"); + sessionManager.appendMessage({ + role: "user", + content: [{ type: "text", text: "restored transcript ".repeat(200_000) }], + timestamp: Date.now(), + }); + + // when + const error = await createAgentSession({ + cwd: harness.tempDir, + agentDir: join(harness.tempDir, "fallback-target-agent"), + sessionManager, + modelRegistry: harness.modelRegistry, + authStorage: harness.authStorage, + }).then( + () => undefined, + (reason: unknown) => reason, + ); + + // then + expect(error).toBeInstanceOf(ModelUsabilityBudgetError); + if (!(error instanceof ModelUsabilityBudgetError)) throw new Error("expected fallback resume rejection"); + expect(error.projection).toMatchObject({ + model: `${fallback.provider}/${fallback.id}`, + usable: false, + admission: "resume", + speculationLeadTokens: 0, + }); + expect(error.projection.liveContextTokens).toBeGreaterThan(0); + }); + it("accepts the exact minimum and rejects one token below it", async () => { // given const harness = await createHarness(); diff --git a/packages/coding-agent/test/suite/terminal-bash-output-width.test.ts b/packages/coding-agent/test/suite/terminal-bash-output-width.test.ts new file mode 100644 index 0000000000..7af6031a76 --- /dev/null +++ b/packages/coding-agent/test/suite/terminal-bash-output-width.test.ts @@ -0,0 +1,142 @@ +import { Box, stripTerminalSequences, visibleWidth } from "@earendil-works/pi-tui"; +import { beforeAll, describe, expect, it } from "vitest"; +import { renderBashOutputResult } from "../../src/core/extensions/builtin/terminal/tools/render.ts"; +import type { ToolRenderContext, ToolRenderResultOptions } from "../../src/core/extensions/types.ts"; +import { initTheme, theme } from "../../src/modes/interactive/theme/theme.ts"; + +type BashOutputResult = Parameters[0]; + +const BOX_WIDTH = 98; +const LINE_102 = "c".repeat(102); +const LINE_180 = "p".repeat(180); +const LONG_PATH = + "make[1]: Entering directory '/workspace/project/models/share/Library/src/vendor/third_party/fortran/modules'"; +const LONG_COMMAND = + "nvfortran -module /workspace/project/models/share/include -c -Mpreprocess -r8 -O3 -Munroll=c:1 -Mlre -Mvect=simd -Mcache_align -r8 ModHdf5Utils.f90"; +const ANSI_LONG_LINE = `\x1b[31m${LONG_PATH}\x1b[39m`; +const WIDE_UNICODE_LINE = `wide ${"全".repeat(60)} end`; + +function bashOutputText(): string { + return [ + "status: exited_2 exit_code: 2", + LONG_PATH, + LONG_COMMAND, + LINE_102, + LINE_180, + ANSI_LONG_LINE, + WIDE_UNICODE_LINE, + ].join("\n"); +} + +function renderContext(options: ToolRenderResultOptions): ToolRenderContext { + return { + args: { bash_id: "bash_1" }, + toolCallId: "bash-output-width", + invalidate: () => {}, + lastComponent: undefined, + state: undefined, + cwd: "/tmp/workspace", + executionStarted: true, + argsComplete: true, + isPartial: options.isPartial, + expanded: options.expanded, + showImages: false, + isError: false, + }; +} + +function createResult(options: ToolRenderResultOptions, text = bashOutputText()) { + const result: BashOutputResult = { content: [{ type: "text", text }], details: undefined }; + return renderBashOutputResult(result, options, theme, renderContext(options)); +} + +function renderBox(width: number, options: ToolRenderResultOptions, text = bashOutputText()): string[] { + const box = new Box(1, 1, (value: string) => `\x1b[48;5;22m${value}\x1b[49m`); + box.addChild(createResult(options, text)); + return box.render(width); +} + +function nonWhitespaceCodepoints(text: string): string { + return [...text].filter((ch) => !/\s/u.test(ch)).join(""); +} + +function expectLinesFit(lines: string[], width: number): void { + for (const line of lines) { + expect(visibleWidth(line)).toBeLessThanOrEqual(width); + } +} + +function expectPreserved(lines: string[], original: string): void { + expect(nonWhitespaceCodepoints(lines.map(stripTerminalSequences).join(""))).toBe( + nonWhitespaceCodepoints(stripTerminalSequences(original)), + ); +} + +describe("bash_output completed/expanded width wrap", () => { + beforeAll(() => { + initTheme("dark"); + }); + + it("fits a 98-column Box for complete output with ANSI, CJK, and long command/path lines", () => { + expect(visibleWidth(LINE_102)).toBeGreaterThan(BOX_WIDTH - 2); + expect(visibleWidth(LINE_180)).toBeGreaterThan(BOX_WIDTH - 2); + expect(visibleWidth(LONG_PATH)).toBeGreaterThan(BOX_WIDTH - 2); + expect(visibleWidth(LONG_COMMAND)).toBeGreaterThan(BOX_WIDTH - 2); + expect(visibleWidth(WIDE_UNICODE_LINE)).toBeGreaterThan(BOX_WIDTH - 2); + + const text = bashOutputText(); + const lines = renderBox(BOX_WIDTH, { expanded: false, isPartial: false }, text); + expect(lines.length).toBeGreaterThan(0); + expectLinesFit(lines, BOX_WIDTH); + expectPreserved(lines, text); + expect(lines.join("")).toContain("\x1b[31m"); + }); + + it("fits a 98-column Box for expanded partial output", () => { + const text = bashOutputText(); + const lines = renderBox(BOX_WIDTH, { expanded: true, isPartial: true }, text); + expectLinesFit(lines, BOX_WIDTH); + expectPreserved(lines, text); + }); + + it("wraps complete component output across resize widths without dropping tokens", () => { + const text = bashOutputText(); + const component = createResult({ expanded: false, isPartial: false }, text); + for (const width of [24, 40, 80, BOX_WIDTH, 160]) { + const lines = component.render(width); + expectLinesFit(lines, width); + expectPreserved(lines, text); + expect(lines.join("")).toContain("\x1b[31m"); + } + }); + + it("wraps expanded ANSI and CJK lines without dropping tokens", () => { + const text = bashOutputText(); + const component = createResult({ expanded: true, isPartial: true }, text); + for (const width of [20, 41, 97, 98]) { + const lines = component.render(width); + expectLinesFit(lines, width); + expectPreserved(lines, text); + const payload = lines.join(""); + expect(payload).toContain("\x1b[31m"); + expect(payload).toContain("全"); + } + }); + + it("keeps partial preview truncated while preserving remaining non-whitespace characters", () => { + const text = Array.from( + { length: 40 }, + (_, index) => `preview-line-${String(index).padStart(2, "0")}-${"x".repeat(30)}`, + ).join("\n"); + const preview = createResult({ expanded: false, isPartial: true }, text).render(40); + const expanded = createResult({ expanded: true, isPartial: true }, text).render(40); + expectLinesFit(preview, 40); + expectLinesFit(expanded, 40); + expect(preview.length).toBeLessThan(expanded.length); + const previewChars = nonWhitespaceCodepoints(preview.map(stripTerminalSequences).join("")); + const originalChars = nonWhitespaceCodepoints(stripTerminalSequences(text)); + expect(originalChars.endsWith(previewChars)).toBe(true); + expect(previewChars.length).toBeGreaterThan(0); + expectPreserved(expanded, text); + }); +});