Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions packages/coding-agent/src/core/changes.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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(),
);
Expand Down
13 changes: 10 additions & 3 deletions packages/coding-agent/src/core/sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
151 changes: 151 additions & 0 deletions packages/coding-agent/test/suite/model-usability-budget.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
142 changes: 142 additions & 0 deletions packages/coding-agent/test/suite/terminal-bash-output-width.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof renderBashOutputResult>[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);
});
});
Loading