COD-80 centralize terminal history/scrollback/buffer limits into config

Introduce src/config/terminal-history.ts: one place for terminal scrollback,
tmux history-limit, and PTY buffer byte caps, each overridable via env var or
the settings object and bounds-clamped via resolveTerminalHistoryConfig().
Defaults match the prior hardcoded values, so this is behavior-neutral. Wires
the resolver through buffer-limits, tmux-manager (incl. a setHistoryLimit so a
settings change applies live), session, server, system-routes, session-routes,
schemas, and the config port. Adds 4 optional settings keys (terminalScrollback
Lines, tmuxHistoryLimit, terminalBufferMaxBytes, terminalBufferTrimBytes) with
bounds + a trim<=max cross-check.
This commit is contained in:
Aamer Akhter
2026-06-30 19:52:49 -04:00
parent abb6447f66
commit 8c0c94540c
13 changed files with 355 additions and 6 deletions
+3
View File
@@ -9,6 +9,7 @@
*/
import { vi } from 'vitest';
import { MockSession, createMockSession } from './mock-session.js';
import { resolveTerminalHistoryConfig } from '../../src/config/terminal-history.js';
/**
* Creates a mock context that satisfies all port interfaces.
@@ -76,6 +77,7 @@ export function createMockRouteContext(options?: { sessionId?: string }) {
getGlobalNiceConfig: vi.fn(async () => undefined),
getModelConfig: vi.fn(async () => null),
getClaudeModeConfig: vi.fn(async () => ({})),
getTerminalHistoryConfig: vi.fn(async () => resolveTerminalHistoryConfig({})),
getDefaultClaudeMdPath: vi.fn(async () => undefined),
getLightState: vi.fn(() => ({ sessions: [], status: 'ok' })),
getLightSessionsState: vi.fn(() => {
@@ -99,6 +101,7 @@ export function createMockRouteContext(options?: { sessionId?: string }) {
getSession: vi.fn(() => null),
clearRespawnConfig: vi.fn(),
updateRespawnConfig: vi.fn(),
setHistoryLimit: vi.fn(async () => {}),
},
runSummaryTrackers: new Map(),
activePlanOrchestrators: new Map(),
+67
View File
@@ -0,0 +1,67 @@
import { describe, it, expect } from 'vitest';
import { SettingsUpdateSchema } from '../src/web/schemas.js';
import {
MAX_TERMINAL_SCROLLBACK_LINES,
MIN_TERMINAL_SCROLLBACK_LINES,
MAX_TERMINAL_BUFFER_BYTES,
MIN_TERMINAL_BUFFER_BYTES,
} from '../src/config/terminal-history.js';
describe('SettingsUpdateSchema — terminal history keys', () => {
it('accepts valid in-range terminal-history settings', () => {
const res = SettingsUpdateSchema.safeParse({
terminalScrollbackLines: 100_000,
tmuxHistoryLimit: 100_000,
terminalBufferMaxBytes: 32 * 1024 * 1024,
terminalBufferTrimBytes: 24 * 1024 * 1024,
});
expect(res.success).toBe(true);
});
it('accepts a settings object that omits the terminal-history keys', () => {
const res = SettingsUpdateSchema.safeParse({ allowedTools: 'Bash' });
expect(res.success).toBe(true);
});
it('rejects scrollback below the minimum', () => {
const res = SettingsUpdateSchema.safeParse({ terminalScrollbackLines: MIN_TERMINAL_SCROLLBACK_LINES - 1 });
expect(res.success).toBe(false);
});
it('rejects scrollback above the maximum', () => {
const res = SettingsUpdateSchema.safeParse({ tmuxHistoryLimit: MAX_TERMINAL_SCROLLBACK_LINES + 1 });
expect(res.success).toBe(false);
});
it('rejects buffer bytes outside the byte bounds', () => {
expect(SettingsUpdateSchema.safeParse({ terminalBufferMaxBytes: MIN_TERMINAL_BUFFER_BYTES - 1 }).success).toBe(
false
);
expect(SettingsUpdateSchema.safeParse({ terminalBufferMaxBytes: MAX_TERMINAL_BUFFER_BYTES + 1 }).success).toBe(
false
);
});
it('rejects non-integer values', () => {
expect(SettingsUpdateSchema.safeParse({ terminalScrollbackLines: 12_345.5 }).success).toBe(false);
});
it('rejects trim > max via the cross-field superRefine', () => {
const res = SettingsUpdateSchema.safeParse({
terminalBufferMaxBytes: 4 * 1024 * 1024,
terminalBufferTrimBytes: 8 * 1024 * 1024,
});
expect(res.success).toBe(false);
if (!res.success) {
expect(res.error.issues.some((i) => i.path.includes('terminalBufferTrimBytes'))).toBe(true);
}
});
it('allows trim == max', () => {
const res = SettingsUpdateSchema.safeParse({
terminalBufferMaxBytes: 8 * 1024 * 1024,
terminalBufferTrimBytes: 8 * 1024 * 1024,
});
expect(res.success).toBe(true);
});
});
+110
View File
@@ -0,0 +1,110 @@
import { describe, it, expect } from 'vitest';
import {
resolveTerminalHistoryConfig,
DEFAULT_TERMINAL_SCROLLBACK_LINES,
DEFAULT_TMUX_HISTORY_LIMIT,
DEFAULT_TERMINAL_BUFFER_MAX_BYTES,
DEFAULT_TERMINAL_BUFFER_TRIM_BYTES,
MIN_TERMINAL_SCROLLBACK_LINES,
MAX_TERMINAL_SCROLLBACK_LINES,
MIN_TERMINAL_BUFFER_BYTES,
MAX_TERMINAL_BUFFER_BYTES,
} from '../src/config/terminal-history.js';
describe('resolveTerminalHistoryConfig', () => {
it('returns the documented defaults for empty settings', () => {
const cfg = resolveTerminalHistoryConfig({});
expect(cfg).toEqual({
terminalScrollbackLines: DEFAULT_TERMINAL_SCROLLBACK_LINES,
tmuxHistoryLimit: DEFAULT_TMUX_HISTORY_LIMIT,
terminalBufferMaxBytes: DEFAULT_TERMINAL_BUFFER_MAX_BYTES,
terminalBufferTrimBytes: DEFAULT_TERMINAL_BUFFER_TRIM_BYTES,
});
});
it('defaults match the prior hardcoded values (behavior-neutral)', () => {
expect(DEFAULT_TMUX_HISTORY_LIMIT).toBe(50_000);
expect(DEFAULT_TERMINAL_SCROLLBACK_LINES).toBe(50_000);
expect(DEFAULT_TERMINAL_BUFFER_MAX_BYTES).toBe(2 * 1024 * 1024);
expect(DEFAULT_TERMINAL_BUFFER_TRIM_BYTES).toBe(1.5 * 1024 * 1024);
});
it('passes valid in-range values through unchanged', () => {
const cfg = resolveTerminalHistoryConfig({
terminalScrollbackLines: 50_000,
tmuxHistoryLimit: 75_000,
terminalBufferMaxBytes: 16 * 1024 * 1024,
terminalBufferTrimBytes: 8 * 1024 * 1024,
});
expect(cfg).toEqual({
terminalScrollbackLines: 50_000,
tmuxHistoryLimit: 75_000,
terminalBufferMaxBytes: 16 * 1024 * 1024,
terminalBufferTrimBytes: 8 * 1024 * 1024,
});
});
it('clamps scrollback/history below MIN up to the floor', () => {
const cfg = resolveTerminalHistoryConfig({
terminalScrollbackLines: 1,
tmuxHistoryLimit: 0,
});
expect(cfg.terminalScrollbackLines).toBe(MIN_TERMINAL_SCROLLBACK_LINES);
expect(cfg.tmuxHistoryLimit).toBe(MIN_TERMINAL_SCROLLBACK_LINES);
});
it('clamps scrollback/history above MAX down to the ceiling', () => {
const cfg = resolveTerminalHistoryConfig({
terminalScrollbackLines: 999_999_999,
tmuxHistoryLimit: 999_999_999,
});
expect(cfg.terminalScrollbackLines).toBe(MAX_TERMINAL_SCROLLBACK_LINES);
expect(cfg.tmuxHistoryLimit).toBe(MAX_TERMINAL_SCROLLBACK_LINES);
});
it('clamps the buffer byte caps to their MIN/MAX bounds', () => {
const tooSmall = resolveTerminalHistoryConfig({ terminalBufferMaxBytes: 1 });
expect(tooSmall.terminalBufferMaxBytes).toBe(MIN_TERMINAL_BUFFER_BYTES);
const tooLarge = resolveTerminalHistoryConfig({ terminalBufferMaxBytes: 1024 * 1024 * 1024 });
expect(tooLarge.terminalBufferMaxBytes).toBe(MAX_TERMINAL_BUFFER_BYTES);
});
it('keeps trim <= max (trim is capped to the resolved max)', () => {
const cfg = resolveTerminalHistoryConfig({
terminalBufferMaxBytes: 4 * 1024 * 1024,
terminalBufferTrimBytes: 64 * 1024 * 1024,
});
expect(cfg.terminalBufferTrimBytes).toBeLessThanOrEqual(cfg.terminalBufferMaxBytes);
expect(cfg.terminalBufferTrimBytes).toBe(4 * 1024 * 1024);
});
it('caps the default trim to a lowered max', () => {
// Default trim (1.5MB) exceeds a 1MB max → trim must fall to the max.
const cfg = resolveTerminalHistoryConfig({ terminalBufferMaxBytes: 1 * 1024 * 1024 });
expect(cfg.terminalBufferTrimBytes).toBe(1 * 1024 * 1024);
});
it('truncates fractional inputs to integers', () => {
const cfg = resolveTerminalHistoryConfig({ terminalScrollbackLines: 12_345.9 });
expect(cfg.terminalScrollbackLines).toBe(12_345);
});
it('falls back to defaults for non-number / non-finite inputs', () => {
const cfg = resolveTerminalHistoryConfig({
terminalScrollbackLines: 'lots' as unknown as number,
tmuxHistoryLimit: NaN,
terminalBufferMaxBytes: null as unknown as number,
terminalBufferTrimBytes: Infinity,
});
expect(cfg.terminalScrollbackLines).toBe(DEFAULT_TERMINAL_SCROLLBACK_LINES);
expect(cfg.tmuxHistoryLimit).toBe(DEFAULT_TMUX_HISTORY_LIMIT);
expect(cfg.terminalBufferMaxBytes).toBe(DEFAULT_TERMINAL_BUFFER_MAX_BYTES);
// trim default is min(DEFAULT_TRIM, resolvedMax) — here resolvedMax is the default max.
expect(cfg.terminalBufferTrimBytes).toBe(DEFAULT_TERMINAL_BUFFER_TRIM_BYTES);
});
it('uses an empty object when called with no argument', () => {
expect(resolveTerminalHistoryConfig().tmuxHistoryLimit).toBe(DEFAULT_TMUX_HISTORY_LIMIT);
});
});