Skip to content
Merged
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
19 changes: 6 additions & 13 deletions packages/cli/src/generated/surfaces/app-terminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string,string|undefined>, 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) {
Expand All @@ -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') {
Expand All @@ -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');
Expand Down
11 changes: 2 additions & 9 deletions packages/cli/src/kern/surfaces/app-terminal.kern
Original file line number Diff line number Diff line change
Expand Up @@ -32,18 +32,11 @@ module name=AppTerminal
>>>

fn name=resolveTerminalMode params="value:unknown,env?:Record<string,string|undefined>,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
Expand Down
25 changes: 22 additions & 3 deletions packages/core/src/generated/sessions/companion-dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ export async function companionDispatch(opts: {config:CompanionConfig, binaryPat
let nextId = 1;
const pending = new Map<number, { resolve: (v: any) => void; reject: (e: Error) => void; timer: NodeJS.Timeout }>();
const agentMessages: string[] = [];
let lastAcpPushWasChunk = false;
let turnCompleted: Record<string, unknown> | null = null;
let turnError: Record<string, unknown> | null = null;
let threadId: string | null = null;
Expand Down Expand Up @@ -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<string, unknown>);
Expand Down Expand Up @@ -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)
Expand Down
35 changes: 34 additions & 1 deletion packages/core/src/generated/sessions/session-acp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,39 @@ export function createAcpSession(config: PersistentSessionConfig): PersistentSes
});
}

function toAcpMcpServers(servers: Array<Record<string, unknown>> | undefined): Array<Record<string, unknown>> {
// 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<string,string> 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<string, unknown>).map(([name, value]) => ({ name, value: String(value) }));
const out: Array<Record<string, unknown>> = [];
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 {
Expand Down Expand Up @@ -248,7 +281,7 @@ export function createAcpSession(config: PersistentSessionConfig): PersistentSes
// Create session (pass system prompt if available)
const sessParams: Record<string, unknown> = {
cwd: config.cwd,
mcpServers: config.mcpServers ?? [],
mcpServers: toAcpMcpServers(config.mcpServers),
};
if (config.systemPrompt) {
sessParams.systemPrompt = config.systemPrompt;
Expand Down
25 changes: 22 additions & 3 deletions packages/core/src/kern/sessions/companion-dispatch.kern
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ fn name=companionDispatch async=true params="opts:{config:CompanionConfig, binar
let nextId = 1;
const pending = new Map<number, { resolve: (v: any) => void; reject: (e: Error) => void; timer: NodeJS.Timeout }>();
const agentMessages: string[] = [];
let lastAcpPushWasChunk = false;
let turnCompleted: Record<string, unknown> | null = null;
let turnError: Record<string, unknown> | null = null;
let threadId: string | null = null;
Expand Down Expand Up @@ -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<string, unknown>);
Expand Down Expand Up @@ -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)
Expand Down
35 changes: 34 additions & 1 deletion packages/core/src/kern/sessions/session-acp.kern
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,39 @@ fn name=createAcpSession params="config:PersistentSessionConfig" returns="Persis
});
}

function toAcpMcpServers(servers: Array<Record<string, unknown>> | undefined): Array<Record<string, unknown>> {
// 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<string,string> 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<string, unknown>).map(([name, value]) => ({ name, value: String(value) }));
const out: Array<Record<string, unknown>> = [];
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 {
Expand Down Expand Up @@ -241,7 +274,7 @@ fn name=createAcpSession params="config:PersistentSessionConfig" returns="Persis
// Create session (pass system prompt if available)
const sessParams: Record<string, unknown> = {
cwd: config.cwd,
mcpServers: config.mcpServers ?? [],
mcpServers: toAcpMcpServers(config.mcpServers),
};
if (config.systemPrompt) {
sessParams.systemPrompt = config.systemPrompt;
Expand Down
9 changes: 7 additions & 2 deletions tests/unit/app-scroll.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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',
Expand Down
39 changes: 39 additions & 0 deletions tests/unit/companion-dispatch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.");
});
});
Loading