From f91789c05bbf8c5c691b2cd12abd0cd54b5cb06b Mon Sep 17 00:00:00 2001 From: "agon (KERN)" <292465531+KERN-Agon@users.noreply.github.com> Date: Thu, 16 Jul 2026 17:21:20 +0200 Subject: [PATCH 1/2] fix(acp): kimi-code session boot + word-per-paragraph replies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two strict-ACP compatibility fixes surfaced by kimi-code as Cesar brain: - session-acp: normalize mcpServers to the ACP-spec wire shape before session/new — stdio entries need args/env as arrays (env as {name,value} pairs), http/sse entries need url + headers pairs. Agon's internal Record-shaped env made strict agents (kimi) reject session/new with -32602 Invalid params, killing the Cesar session every turn. Malformed entries (http without url, stdio without command) are skipped instead of emitting undefined required fields. - companion-dispatch: concatenate consecutive agent_message_chunk deltas into one message instead of pushing each token separately — the '\n\n' join rendered kimi fallback replies one word per paragraph. tool_call updates and item/completed pushes end a chunk run; the prompt-response text fallback only fires when nothing was streamed, so it can no longer duplicate the streamed answer. ⚔️ Forged by [Agon](https://github.com/KERNlang/agon) Co-Authored-By: agon (KERN) <292465531+KERN-Agon@users.noreply.github.com> --- .../generated/sessions/companion-dispatch.ts | 25 +++++- .../src/generated/sessions/session-acp.ts | 35 +++++++- .../src/kern/sessions/companion-dispatch.kern | 25 +++++- .../core/src/kern/sessions/session-acp.kern | 35 +++++++- tests/unit/companion-dispatch.test.ts | 39 +++++++++ tests/unit/session-acp-mcp.test.ts | 80 +++++++++++++++++++ 6 files changed, 231 insertions(+), 8 deletions(-) create mode 100644 tests/unit/session-acp-mcp.test.ts diff --git a/packages/core/src/generated/sessions/companion-dispatch.ts b/packages/core/src/generated/sessions/companion-dispatch.ts index b601ae68c..e963337a5 100644 --- a/packages/core/src/generated/sessions/companion-dispatch.ts +++ b/packages/core/src/generated/sessions/companion-dispatch.ts @@ -81,6 +81,7 @@ export async function companionDispatch(opts: {config:CompanionConfig, binaryPat let nextId = 1; const pending = new Map void; reject: (e: Error) => void; timer: NodeJS.Timeout }>(); const agentMessages: string[] = []; + let lastAcpPushWasChunk = false; let turnCompleted: Record | null = null; let turnError: Record | null = null; let threadId: string | null = null; @@ -244,16 +245,32 @@ export async function companionDispatch(opts: {config:CompanionConfig, binaryPat const item = (msg.params as any)?.item; if (item?.type === 'agentMessage') { agentMessages.push(item.text); + lastAcpPushWasChunk = false; } if (item?.type === 'enteredReviewMode') { agentMessages.push(item.review); + lastAcpPushWasChunk = false; } } else if (msg.method === 'session/update') { // ACP protocol notifications const update = (msg.params as any)?.update; if (update?.sessionUpdate === 'agent_message_chunk') { + // Chunks are token-level DELTAS of one agent message (kimi streams + // per-word) — concatenate consecutive chunks into a single entry. + // Pushing each delta separately rendered one word per '\n\n' paragraph. const text = update.content?.text ?? ''; - if (text) agentMessages.push(text); + if (text) { + if (lastAcpPushWasChunk && agentMessages.length > 0) { + agentMessages[agentMessages.length - 1] += text; + } else { + agentMessages.push(text); + } + lastAcpPushWasChunk = true; + } + } else if (update?.sessionUpdate === 'tool_call') { + // A tool call ends the contiguous text run — the next chunk starts a + // new message (kept as a separate '\n\n'-joined paragraph block). + lastAcpPushWasChunk = false; } } else if (msg.method === 'error') { turnError = (msg.params as Record); @@ -364,8 +381,10 @@ export async function companionDispatch(opts: {config:CompanionConfig, binaryPat prompt: [{ type: 'text', text: opts.prompt }], }) as any; - // ACP returns text directly in the prompt response - if (promptResult?.text) agentMessages.push(promptResult.text); + // Some ACP servers return the text directly in the prompt response — + // but only as a fallback when nothing was streamed via + // agent_message_chunk, else the trailing text would duplicate. + if (promptResult?.text && agentMessages.length === 0) agentMessages.push(promptResult.text); turnCompleted = promptResult ?? {}; } else { // JSONRPC protocol (Codex) diff --git a/packages/core/src/generated/sessions/session-acp.ts b/packages/core/src/generated/sessions/session-acp.ts index ef46a7caf..28612ab3b 100644 --- a/packages/core/src/generated/sessions/session-acp.ts +++ b/packages/core/src/generated/sessions/session-acp.ts @@ -38,6 +38,39 @@ export function createAcpSession(config: PersistentSessionConfig): PersistentSes }); } + function toAcpMcpServers(servers: Array> | undefined): Array> { + // ACP spec wire shape: stdio servers carry env as EnvVariable[] ({name,value}) + // and REQUIRE args/env arrays; http/sse servers carry headers the same way. + // Agon's internal shape uses Record env — strict ACP agents + // (kimi) reject that with -32602 Invalid params, lenient ones ignored it. + const toPairs = (rec: unknown): Array<{ name: string; value: string }> => + Object.entries((rec ?? {}) as Record).map(([name, value]) => ({ name, value: String(value) })); + const out: Array> = []; + for (const s of (servers ?? []) as any[]) { + // A malformed entry (http-typed without url, or stdio without command) + // would emit a wire object with undefined required fields — the exact + // -32602 class this normalizer exists to prevent. Skip it instead. + if (s.url || s.type === 'http' || s.type === 'sse') { + if (!s.url) continue; + out.push({ + type: s.type === 'sse' ? 'sse' : 'http', + name: s.name, + url: s.url, + headers: Array.isArray(s.headers) ? s.headers : toPairs(s.headers), + }); + continue; + } + if (!s.command) continue; + out.push({ + name: s.name, + command: s.command, + args: Array.isArray(s.args) ? s.args : [], + env: Array.isArray(s.env) ? s.env : toPairs(s.env), + }); + } + return out; + } + function killProc(): void { if (!proc) return; try { @@ -248,7 +281,7 @@ export function createAcpSession(config: PersistentSessionConfig): PersistentSes // Create session (pass system prompt if available) const sessParams: Record = { cwd: config.cwd, - mcpServers: config.mcpServers ?? [], + mcpServers: toAcpMcpServers(config.mcpServers), }; if (config.systemPrompt) { sessParams.systemPrompt = config.systemPrompt; diff --git a/packages/core/src/kern/sessions/companion-dispatch.kern b/packages/core/src/kern/sessions/companion-dispatch.kern index fc9099f0b..1c9724b2c 100644 --- a/packages/core/src/kern/sessions/companion-dispatch.kern +++ b/packages/core/src/kern/sessions/companion-dispatch.kern @@ -73,6 +73,7 @@ fn name=companionDispatch async=true params="opts:{config:CompanionConfig, binar let nextId = 1; const pending = new Map void; reject: (e: Error) => void; timer: NodeJS.Timeout }>(); const agentMessages: string[] = []; + let lastAcpPushWasChunk = false; let turnCompleted: Record | null = null; let turnError: Record | null = null; let threadId: string | null = null; @@ -236,16 +237,32 @@ fn name=companionDispatch async=true params="opts:{config:CompanionConfig, binar const item = (msg.params as any)?.item; if (item?.type === 'agentMessage') { agentMessages.push(item.text); + lastAcpPushWasChunk = false; } if (item?.type === 'enteredReviewMode') { agentMessages.push(item.review); + lastAcpPushWasChunk = false; } } else if (msg.method === 'session/update') { // ACP protocol notifications const update = (msg.params as any)?.update; if (update?.sessionUpdate === 'agent_message_chunk') { + // Chunks are token-level DELTAS of one agent message (kimi streams + // per-word) — concatenate consecutive chunks into a single entry. + // Pushing each delta separately rendered one word per '\n\n' paragraph. const text = update.content?.text ?? ''; - if (text) agentMessages.push(text); + if (text) { + if (lastAcpPushWasChunk && agentMessages.length > 0) { + agentMessages[agentMessages.length - 1] += text; + } else { + agentMessages.push(text); + } + lastAcpPushWasChunk = true; + } + } else if (update?.sessionUpdate === 'tool_call') { + // A tool call ends the contiguous text run — the next chunk starts a + // new message (kept as a separate '\n\n'-joined paragraph block). + lastAcpPushWasChunk = false; } } else if (msg.method === 'error') { turnError = (msg.params as Record); @@ -356,8 +373,10 @@ fn name=companionDispatch async=true params="opts:{config:CompanionConfig, binar prompt: [{ type: 'text', text: opts.prompt }], }) as any; - // ACP returns text directly in the prompt response - if (promptResult?.text) agentMessages.push(promptResult.text); + // Some ACP servers return the text directly in the prompt response — + // but only as a fallback when nothing was streamed via + // agent_message_chunk, else the trailing text would duplicate. + if (promptResult?.text && agentMessages.length === 0) agentMessages.push(promptResult.text); turnCompleted = promptResult ?? {}; } else { // JSONRPC protocol (Codex) diff --git a/packages/core/src/kern/sessions/session-acp.kern b/packages/core/src/kern/sessions/session-acp.kern index 4a4167fed..4fd67a6d1 100644 --- a/packages/core/src/kern/sessions/session-acp.kern +++ b/packages/core/src/kern/sessions/session-acp.kern @@ -31,6 +31,39 @@ fn name=createAcpSession params="config:PersistentSessionConfig" returns="Persis }); } + function toAcpMcpServers(servers: Array> | undefined): Array> { + // ACP spec wire shape: stdio servers carry env as EnvVariable[] ({name,value}) + // and REQUIRE args/env arrays; http/sse servers carry headers the same way. + // Agon's internal shape uses Record env — strict ACP agents + // (kimi) reject that with -32602 Invalid params, lenient ones ignored it. + const toPairs = (rec: unknown): Array<{ name: string; value: string }> => + Object.entries((rec ?? {}) as Record).map(([name, value]) => ({ name, value: String(value) })); + const out: Array> = []; + for (const s of (servers ?? []) as any[]) { + // A malformed entry (http-typed without url, or stdio without command) + // would emit a wire object with undefined required fields — the exact + // -32602 class this normalizer exists to prevent. Skip it instead. + if (s.url || s.type === 'http' || s.type === 'sse') { + if (!s.url) continue; + out.push({ + type: s.type === 'sse' ? 'sse' : 'http', + name: s.name, + url: s.url, + headers: Array.isArray(s.headers) ? s.headers : toPairs(s.headers), + }); + continue; + } + if (!s.command) continue; + out.push({ + name: s.name, + command: s.command, + args: Array.isArray(s.args) ? s.args : [], + env: Array.isArray(s.env) ? s.env : toPairs(s.env), + }); + } + return out; + } + function killProc(): void { if (!proc) return; try { @@ -241,7 +274,7 @@ fn name=createAcpSession params="config:PersistentSessionConfig" returns="Persis // Create session (pass system prompt if available) const sessParams: Record = { cwd: config.cwd, - mcpServers: config.mcpServers ?? [], + mcpServers: toAcpMcpServers(config.mcpServers), }; if (config.systemPrompt) { sessParams.systemPrompt = config.systemPrompt; diff --git a/tests/unit/companion-dispatch.test.ts b/tests/unit/companion-dispatch.test.ts index ad05c8937..972deb8d7 100644 --- a/tests/unit/companion-dispatch.test.ts +++ b/tests/unit/companion-dispatch.test.ts @@ -44,4 +44,43 @@ describe('companionDispatch', () => { expect(result.stdout).toBe('ok'); expect(Date.now() - startedAt).toBeLessThan(3000); }); + + it('concatenates token-level ACP agent_message_chunk deltas instead of one word per paragraph', async () => { + // Fake ACP server: answers initialize/session/new, then streams the agent + // message as per-word chunks (kimi style) with a tool_call in the middle, + // then resolves session/prompt. + const script = [ + "const rl = require('node:readline').createInterface({ input: process.stdin });", + "const w = (o) => process.stdout.write(JSON.stringify(o) + '\\n');", + "const chunk = (text) => w({ jsonrpc: '2.0', method: 'session/update', params: { update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text } } } });", + "rl.on('line', (line) => {", + " const msg = JSON.parse(line);", + " if (msg.method === 'initialize') w({ jsonrpc: '2.0', id: msg.id, result: { protocolVersion: 1 } });", + " if (msg.method === 'session/new') w({ jsonrpc: '2.0', id: msg.id, result: { sessionId: 's1' } });", + " if (msg.method === 'session/prompt') {", + " chunk('I\\'ll'); chunk(' start'); chunk(' by');", + " w({ jsonrpc: '2.0', method: 'session/update', params: { update: { sessionUpdate: 'tool_call', toolCallId: 't1', title: 'ls', status: 'completed' } } });", + " chunk(' Done'); chunk('.');", + " w({ jsonrpc: '2.0', id: msg.id, result: { stopReason: 'end_turn' } });", + " }", + "});", + ].join(''); + + const result = await companionDispatch({ + binaryPath: process.execPath, + config: { + protocol: 'acp', + serverCmd: ['-e', script], + }, + prompt: 'hello', + cwd: process.cwd(), + timeout: 5, + mode: 'exec', + }); + + expect(result.exitCode).toBe(0); + // Chunks within a run concatenate verbatim; the tool_call splits the runs + // into two '\n\n'-joined paragraphs. + expect(result.stdout).toBe("I'll start by\n\n Done."); + }); }); diff --git a/tests/unit/session-acp-mcp.test.ts b/tests/unit/session-acp-mcp.test.ts new file mode 100644 index 000000000..974b5fb0e --- /dev/null +++ b/tests/unit/session-acp-mcp.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest'; + +import { createAcpSession } from '../../packages/core/src/generated/sessions/session-acp.js'; + +// Strict ACP agents (kimi) validate session/new params against the spec: +// stdio mcpServers entries REQUIRE args/env with env as EnvVariable[] +// ({name,value} pairs), and http entries require headers the same way. +// Agon's internal wire shape uses Record env — sending it +// verbatim produced "ACP error -32602: Invalid params" and killed the +// Cesar session. createAcpSession must normalize before session/new. +describe('createAcpSession mcpServers normalization', () => { + // Fake strict server: rejects session/new with -32602 unless every entry is + // spec-shaped — stdio needs array args/env ({name,value} pairs) and a + // command; http/sse needs a url and array headers. + const strictServerScript = [ + "const rl = require('node:readline').createInterface({ input: process.stdin });", + "const w = (o) => process.stdout.write(JSON.stringify(o) + '\\n');", + "const pairs = (a) => Array.isArray(a) && a.every((e) => typeof e.name === 'string' && typeof e.value === 'string');", + "rl.on('line', (line) => {", + " const msg = JSON.parse(line);", + " if (msg.method === 'initialize') w({ jsonrpc: '2.0', id: msg.id, result: { protocolVersion: 1 } });", + " if (msg.method === 'session/new') {", + " const servers = msg.params.mcpServers ?? [];", + " const ok = servers.every((s) => (s.type === 'http' || s.type === 'sse')", + " ? (typeof s.url === 'string' && pairs(s.headers))", + " : (typeof s.command === 'string' && Array.isArray(s.args) && pairs(s.env)));", + " if (ok) w({ jsonrpc: '2.0', id: msg.id, result: { sessionId: 's1', received: servers } });", + " else w({ jsonrpc: '2.0', id: msg.id, error: { code: -32602, message: 'Invalid params' } });", + " }", + "});", + ].join(''); + + function makeSession(mcpServers: Array>) { + return createAcpSession({ + engine: { id: 'fake-acp', companion: { protocol: 'acp', serverCmd: ['-e', strictServerScript] } }, + binaryPath: process.execPath, + cwd: process.cwd(), + mcpServers, + } as never); + } + + it('boots against a strict ACP server when config.mcpServers uses Record env', async () => { + const session = makeSession([ + { + name: 'agon-orchestration', + command: 'node', + args: ['/tmp/server.js'], + env: { AGON_SIGNAL_DIR: '/tmp', AGON_SESSION_ID: 'test' }, + }, + ]); + + try { + await session.start(); + expect(session.alive).toBe(true); + expect(session.sessionId).toBe('s1'); + } finally { + session.close(); + } + }); + + it('normalizes http entries (Record headers) and skips malformed entries', async () => { + const session = makeSession([ + // http entry with Record headers → headers must become {name,value}[] + { name: 'remote', type: 'http', url: 'https://example.com/mcp', headers: { Authorization: 'Bearer x' } }, + // http-typed entry WITHOUT url → must be skipped, not fall through to + // the stdio shape with command: undefined + { name: 'broken-http', type: 'http' }, + // stdio entry without command → must be skipped + { name: 'broken-stdio', env: { A: 'b' } }, + ]); + + try { + await session.start(); + expect(session.alive).toBe(true); + expect(session.sessionId).toBe('s1'); + } finally { + session.close(); + } + }); +}); From 68109abf4e072320072a4ceb394191834fb18fcf Mon Sep 17 00:00:00 2001 From: "agon (KERN)" <292465531+KERN-Agon@users.noreply.github.com> Date: Thu, 16 Jul 2026 17:48:28 +0200 Subject: [PATCH 2/2] =?UTF-8?q?fix(cli):=20auto=20terminal=20mode=20is=20n?= =?UTF-8?q?ative=20again=20=E2=80=94=20fullscreen=20is=20opt-in=20only?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 3facf1d2 made the auto policy resolve to fullscreen (alternate screen) for every interactive TTY, which silently killed terminal scrollback for all users: the alt screen has no scrollback, and the transcript no longer sealed into it via Static. Native mode IS the intended default experience — finished blocks spill into real scrollback while the composer/status stay pinned at the bottom. Fullscreen stays available strictly via `agon config terminalMode fullscreen`. ⚔️ Forged by [Agon](https://github.com/KERNlang/agon) Co-Authored-By: agon (KERN) <292465531+KERN-Agon@users.noreply.github.com> --- .../src/generated/surfaces/app-terminal.ts | 19 ++++++------------- .../cli/src/kern/surfaces/app-terminal.kern | 11 ++--------- tests/unit/app-scroll.test.ts | 9 +++++++-- 3 files changed, 15 insertions(+), 24 deletions(-) diff --git a/packages/cli/src/generated/surfaces/app-terminal.ts b/packages/cli/src/generated/surfaces/app-terminal.ts index c0a410d30..e7794ad69 100644 --- a/packages/cli/src/generated/surfaces/app-terminal.ts +++ b/packages/cli/src/generated/surfaces/app-terminal.ts @@ -45,33 +45,26 @@ export function normalizeTerminalMode(value: unknown): 'auto'|'native'|'fullscre } /** - * Resolve auto terminal policy deterministically. Explicit modes win; auto avoids alternate-screen rendering where scrollback or terminal capability would be harmed. + * Resolve the terminal policy deterministically. Explicit modes win. Auto is NATIVE: the Static-sealed transcript lives in real terminal scrollback with the composer/status pinned at the bottom (the Claude-Code/Codex mix). Fullscreen (alternate screen) kills terminal scrollback, so it is strictly opt-in via `agon config terminalMode fullscreen` — never a default (regression 2026-07-16: auto→fullscreen shipped and broke scroll-up for every user). */ // @kern-source: app-terminal:34 export function resolveTerminalMode(value: unknown, env?: Record, isTTY?: boolean): 'native'|'fullscreen' { const configured = normalizeTerminalMode(value); if (configured === 'native' || configured === 'fullscreen') return configured; - const runtimeEnv = env ?? process.env; - const runtimeIsTTY = isTTY ?? Boolean(process.stdout.isTTY); - const truthy = (raw: unknown) => ['1', 'true', 'yes', 'on'].includes(String(raw ?? '').trim().toLowerCase()); - if (!runtimeIsTTY) return 'native'; - if (truthy(runtimeEnv?.AGON_NO_ALT_SCREEN) || truthy(runtimeEnv?.CI)) return 'native'; - if (String(runtimeEnv?.TERM ?? '').trim().toLowerCase() === 'dumb') return 'native'; - if (String(runtimeEnv?.ZELLIJ ?? '').trim() || String(runtimeEnv?.ZELLIJ_SESSION_NAME ?? '').trim()) return 'native'; - return 'fullscreen'; + return 'native'; } /** * Clamp a terminal resize into one atomic viewport value so width and height never render as mismatched frames. */ -// @kern-source: app-terminal:49 +// @kern-source: app-terminal:42 export function normalizeTerminalSize(columns: unknown, rows: unknown): {width:number,height:number} { const width = Math.max(40, Math.floor(Number(columns) || 100)); const height = Math.max(8, Math.floor(Number(rows) || 24)); return { width, height }; } -// @kern-source: app-terminal:57 +// @kern-source: app-terminal:50 export function fileRailWidthForTerminal(termWidth: number, expanded: boolean): number { const safeWidth = Math.max(40, Math.floor((Number(termWidth) || 100))); if (expanded) { @@ -80,7 +73,7 @@ export function fileRailWidthForTerminal(termWidth: number, expanded: boolean): return Math.max(28, Math.min(42, Math.floor((safeWidth * 0.22)))); } -// @kern-source: app-terminal:64 +// @kern-source: app-terminal:57 export function fileRailMaxRowsForTerminal(termHeight: number, terminalMode: string, expanded: boolean): number { const safeHeight = Math.max(8, Math.floor((Number(termHeight) || 24))); if (terminalMode === 'native') { @@ -98,7 +91,7 @@ export function fileRailMaxRowsForTerminal(termHeight: number, terminalMode: str /** * Pure terminal replay harness: summarizes the layout-sensitive parts of the REPL for fixed viewport sizes so unit tests can catch native/fullscreen regressions without launching an interactive TTY. */ -// @kern-source: app-terminal:75 +// @kern-source: app-terminal:68 export function buildTerminalReplaySnapshot(blocks: OutputBlock[], opts: any): {terminalMode:'native'|'fullscreen'; mode:string; termWidth:number; termHeight:number; visibleBudget:number; transcriptRowCount:number; staticBlockCount:number; liveBlockCount:number; fileRailWidth:number; fileRailRows:number; headerRows:number; lowerChromeRows:number} { const terminalMode = resolveTerminalMode(opts?.terminalMode, opts?.env ?? {}, opts?.isTTY ?? true); const mode = String(opts?.mode ?? 'chat'); diff --git a/packages/cli/src/kern/surfaces/app-terminal.kern b/packages/cli/src/kern/surfaces/app-terminal.kern index ed9bbc8b9..69b52fb02 100644 --- a/packages/cli/src/kern/surfaces/app-terminal.kern +++ b/packages/cli/src/kern/surfaces/app-terminal.kern @@ -32,18 +32,11 @@ module name=AppTerminal >>> fn name=resolveTerminalMode params="value:unknown,env?:Record,isTTY?:boolean" returns="'native'|'fullscreen'" export=true - doc "Resolve auto terminal policy deterministically. Explicit modes win; auto avoids alternate-screen rendering where scrollback or terminal capability would be harmed." + doc "Resolve the terminal policy deterministically. Explicit modes win. Auto is NATIVE: the Static-sealed transcript lives in real terminal scrollback with the composer/status pinned at the bottom (the Claude-Code/Codex mix). Fullscreen (alternate screen) kills terminal scrollback, so it is strictly opt-in via `agon config terminalMode fullscreen` — never a default (regression 2026-07-16: auto→fullscreen shipped and broke scroll-up for every user)." handler <<< const configured = normalizeTerminalMode(value); if (configured === 'native' || configured === 'fullscreen') return configured; - const runtimeEnv = env ?? process.env; - const runtimeIsTTY = isTTY ?? Boolean(process.stdout.isTTY); - const truthy = (raw: unknown) => ['1', 'true', 'yes', 'on'].includes(String(raw ?? '').trim().toLowerCase()); - if (!runtimeIsTTY) return 'native'; - if (truthy(runtimeEnv?.AGON_NO_ALT_SCREEN) || truthy(runtimeEnv?.CI)) return 'native'; - if (String(runtimeEnv?.TERM ?? '').trim().toLowerCase() === 'dumb') return 'native'; - if (String(runtimeEnv?.ZELLIJ ?? '').trim() || String(runtimeEnv?.ZELLIJ_SESSION_NAME ?? '').trim()) return 'native'; - return 'fullscreen'; + return 'native'; >>> fn name=normalizeTerminalSize params="columns:unknown,rows:unknown" returns="{width:number,height:number}" export=true diff --git a/tests/unit/app-scroll.test.ts b/tests/unit/app-scroll.test.ts index c8cc8694f..445354bc2 100644 --- a/tests/unit/app-scroll.test.ts +++ b/tests/unit/app-scroll.test.ts @@ -170,7 +170,10 @@ describe('app scroll helpers', () => { expect(normalizeTerminalMode('fullscreen')).toBe('fullscreen'); expect(resolveTerminalMode('native', {}, true)).toBe('native'); expect(resolveTerminalMode('fullscreen', { ZELLIJ: '1' }, false)).toBe('fullscreen'); - expect(resolveTerminalMode('auto', {}, true)).toBe('fullscreen'); + // Auto is NATIVE: real scrollback + pinned composer (the Codex/Claude-Code + // mix). Fullscreen is opt-in only — an auto→fullscreen default shipped once + // and broke scroll-up for every user (2026-07-16). + expect(resolveTerminalMode('auto', {}, true)).toBe('native'); expect(resolveTerminalMode('auto', { ZELLIJ: '1' }, true)).toBe('native'); expect(resolveTerminalMode('auto', { TERM: 'dumb' }, true)).toBe('native'); expect(resolveTerminalMode('auto', { AGON_NO_ALT_SCREEN: '1' }, true)).toBe('native'); @@ -399,7 +402,9 @@ describe('app scroll helpers', () => { }] satisfies OutputBlock[]; const sizes = [[120, 36], [48, 16], [90, 28], [40, 12]] as const; const frames = sizes.map(([termWidth, termHeight]) => buildTerminalReplaySnapshot(blocks, { - terminalMode: 'auto', + // Fullscreen is opt-in only (auto resolves to native) — this test + // exercises the fullscreen viewport budgets, so request it explicitly. + terminalMode: 'fullscreen', env: {}, isTTY: true, mode: 'chat',