From bf0760b2c3ccced3026c29f74a8119bc1034a918 Mon Sep 17 00:00:00 2001 From: BIackFIame <77388790+BIackFIame@users.noreply.github.com> Date: Sun, 1 Nov 2026 08:19:39 +0000 Subject: [PATCH 1/2] fix(orchestration): return a subagent's screen as plain text wait_for_agent, get_agent_result and observe_agent returned the raw PTY tail, which for a full-screen TUI such as OpenCode is cursor moves, colours and spinner redraws, often starting mid-sequence. They now render the masked scrollback window (the last 65,536 characters) through a headless xterm at the session's size and return the text it shows, masked again and capped as before (observe_agent within its maxChars). A cut window starts at its first escape or line break, so the rest of a cut sequence ("18;9H") is never printed as text. Should rendering fail, the masked raw tail is returned as before. exitLines were the regex-stripped last 16 KiB, so a TUI that redraws only the cells that changed read as its redraws joined together, and a line drawn early and never redrawn (the model's status line) was lost. They are now rendered from the same window, masked before and after. observe() and result() become async; their callers await them. --- docs/agent-orchestration.md | 4 +- native/canvastty-helper/catalog.json | 6 +- src/agent-browser/orchestration-catalog.mjs | 6 +- src/main/services/AgentControlService.ts | 90 +++++++--- .../agent-browser/OrchestrationTools.ts | 12 +- src/main/services/materials/sessionScreen.ts | 11 +- src/main/services/renderedTerminalText.ts | 39 +++++ tests/agent-control.test.mjs | 14 +- tests/launch-contributors.test.mjs | 4 +- tests/orchestration-providers-wait.test.mjs | 164 ++++++++++++++++++ tests/secret-redaction.test.mjs | 15 +- 11 files changed, 314 insertions(+), 51 deletions(-) create mode 100644 src/main/services/renderedTerminalText.ts diff --git a/docs/agent-orchestration.md b/docs/agent-orchestration.md index dcf30206c..3293b4cde 100644 --- a/docs/agent-orchestration.md +++ b/docs/agent-orchestration.md @@ -10,8 +10,8 @@ A session launched from the desktop with the **Orchestrator** role gets the `can | --- | --- | | `list_providers` | The agent providers CanvasTTY can launch as subagents: `id` (the exact `spawn_agent.provider` value), `name`, `installed`/`available` (from the provider CLI registry resolved at startup or on a recheck), `signIn` (`ok`, `signed_out`, `expired` or `unknown`, from the last usage-limits read; it never starts a read and never reads credentials), `subagent` and `orchestrator` support, `model` (whether the CLI takes one, its format, and for OpenCode the models a cached `opencode models` listing reported: run in the background with a 5 s timeout, refreshed after 10 minutes, never awaited) and `efforts`, and plugin launch options (`launchOptions`, plus `launchOptionTools` such as an accounts plugin's `list_routes`). Fast: cached state only, no network calls. | | `spawn_agent` | Launches a subagent (profile: see below). `provider` must be a `list_providers` id (the schema lists them); an unknown one is refused with `INVALID_REQUEST` naming `list_providers`. Optional `model` goes to the CLI's own `--model` for that run (OpenCode `provider/model`; Codex, Claude, Qwen, Kimi alias, Grok, OMP, Pi, Cursor; Hermes, MiniMax, Devin and Antigravity take none) and optional `effort` to its reasoning effort (Codex `-c model_reasoning_effort`, Claude `--effort`, Grok `--reasoning-effort`). Both are checked per provider, kept on restart and restore, and never written to the CLI's configuration. If the person names a model, the orchestrator passes it as `model`. An OpenCode model its own `opencode models` list does not contain is refused before launch with up to five closest listed ids (OpenCode would otherwise stop with only "Unexpected server error"); when that list cannot be read, the model is passed as given. A subagent that exits anyway reports the last lines of its screen as `exitLines` in `wait_for_agent`, `observe_agent` and `get_agent_result`. | -| `wait_for_agent` | `{ sessionId, timeoutSeconds ≤ 600 }` (default 55). Returns when the subagent is `idle` (after a prompt: only once a turn that started after the latest delivered prompt has ended, so the CLI's startup idle does not count), `needs_approval`, `done`/`failed` (exited), `quiet` (reports no status and its screen stopped changing), `closed`, or on `timeout`, with `status`, `exitCode`, `waitedMs` and the masked terminal tail (masked before it is cut). Only this orchestrator's own subagents; it stops at once when the call is canceled or the orchestrator disconnects. | -| `get_agent_result`, `observe_agent` | `get_agent_result`: `answer`, the final reply of the subagent's last turn as the agent itself reported it (Codex through its Stop hook, OpenCode through CanvasTTY's plugin reading the session's last assistant message at `session.idle`; at most 4,096 characters with the end kept, masked, in memory only and cleared when the next turn starts), plus `status`, the exit state and the masked terminal tail. Other providers have no `answer`: their tail is raw screen output. `observe_agent`: the current status and tail. | +| `wait_for_agent` | `{ sessionId, timeoutSeconds ≤ 600 }` (default 55). Returns when the subagent is `idle` (after a prompt: only once a turn that started after the latest delivered prompt has ended, so the CLI's startup idle does not count), `needs_approval`, `done`/`failed` (exited), `quiet` (reports no status and its screen stopped changing), `closed`, or on `timeout`, with `status`, `exitCode`, `waitedMs` and the end of the subagent's screen as masked plain text (as `get_agent_result`). Only this orchestrator's own subagents; it stops at once when the call is canceled or the orchestrator disconnects. | +| `get_agent_result`, `observe_agent` | `get_agent_result`: `answer`, the final reply of the subagent's last turn as the agent itself reported it (Codex through its Stop hook, OpenCode through CanvasTTY's plugin reading the session's last assistant message at `session.idle`; at most 4,096 characters with the end kept, masked, in memory only and cleared when the next turn starts), plus `status`, the exit state and the end of the subagent's screen as masked plain text (a headless terminal renders the redraws of a full-screen TUI such as OpenCode from the last 64 KiB of masked output, so no control sequences remain; `wait_for_agent` returns the same). Other providers have no `answer`: read that text. `observe_agent`: the current status and the end of the screen as the same plain text (at most `maxChars`). | | `send_to_agent`, `cancel_agent`, `list_agents` | Follow-up prompts, disposing a subagent, listing this session's subagents. | ### Subagent profiles diff --git a/native/canvastty-helper/catalog.json b/native/canvastty-helper/catalog.json index 2c48ebe28..12896fa43 100644 --- a/native/canvastty-helper/catalog.json +++ b/native/canvastty-helper/catalog.json @@ -1077,7 +1077,7 @@ }, { "name": "observe_agent", - "description": "Read the capped terminal tail and status of one of this session's subagents.", + "description": "Read the status of one of this session's subagents and the end of its screen as masked plain text (once its process exited, also exitLines).", "inputSchema": { "type": "object", "properties": { @@ -1100,7 +1100,7 @@ }, { "name": "wait_for_agent", - "description": "Wait until one of this session's subagents stops working, instead of polling observe_agent or get_agent_result; nothing is sent to it while it waits. Returns reason \"idle\" (the turn that answers your latest prompt ended and it waits for input; an idle before that turn started does not count), \"needs_approval\" (its card shows a prompt only the person may answer; never answer it yourself), \"done\" or \"failed\" (its process exited), \"quiet\" (it reports no status or no turn start and its screen stopped changing, so judge from output), \"closed\" (its card was closed) or \"timeout\" after timeoutSeconds (default 55, at most 600), with status, exitCode, waitedMs, the masked terminal tail as output, and answer (the final reply of the turn that ended, for Codex and OpenCode subagents); when the subagent's process exited (for example at once, on a model its CLI does not know), exitLines holds the last lines of its screen as plain text, which say why. After a timeout, call it again. Then read get_agent_result.", + "description": "Wait until one of this session's subagents stops working, instead of polling observe_agent or get_agent_result; nothing is sent to it while it waits. Returns reason \"idle\" (the turn that answers your latest prompt ended and it waits for input; an idle before that turn started does not count), \"needs_approval\" (its card shows a prompt only the person may answer; never answer it yourself), \"done\" or \"failed\" (its process exited), \"quiet\" (it reports no status or no turn start and its screen stopped changing, so judge from output), \"closed\" (its card was closed) or \"timeout\" after timeoutSeconds (default 55, at most 600), with status, exitCode, waitedMs, the end of its screen as masked plain text as output, and answer (the final reply of the turn that ended, for Codex and OpenCode subagents); when the subagent's process exited (for example at once, on a model its CLI does not know), exitLines holds the last lines of its screen as plain text, which say why. After a timeout, call it again. Then read get_agent_result.", "inputSchema": { "type": "object", "properties": { @@ -1123,7 +1123,7 @@ }, { "name": "get_agent_result", - "description": "Get one of this session's subagents' result: answer (its last turn's final reply as the agent reported it, for Codex and OpenCode subagents; truncated:true when only the end was kept), status (idle once its turn ended), the exit state (running | done | failed; an interactive CLI stays running after a task) and the masked terminal tail as output. Prefer answer; the terminal tail is raw screen output.", + "description": "Get one of this session's subagents' result: answer (its last turn's final reply as the agent reported it, for Codex and OpenCode subagents; truncated:true when only the end was kept), status (idle once its turn ended), the exit state (running | done | failed; an interactive CLI stays running after a task) and the end of its screen as masked plain text as output (what the terminal shows, without control sequences). Prefer answer.", "inputSchema": { "type": "object", "properties": { diff --git a/src/agent-browser/orchestration-catalog.mjs b/src/agent-browser/orchestration-catalog.mjs index e1f1f38ce..1f5c56e5a 100644 --- a/src/agent-browser/orchestration-catalog.mjs +++ b/src/agent-browser/orchestration-catalog.mjs @@ -79,19 +79,19 @@ export const ORCHESTRATION_TOOL_DEFINITIONS = Object.freeze([ ), tool( "observe_agent", - "Read the capped terminal tail and status of one of this session's subagents.", + "Read the status of one of this session's subagents and the end of its screen as masked plain text (once its process exited, also exitLines).", { sessionId, maxChars: integer({ minimum: 256, maximum: 8_192 }) }, ["sessionId"] ), tool( "wait_for_agent", - `Wait until one of this session's subagents stops working, instead of polling observe_agent or get_agent_result; nothing is sent to it while it waits. Returns reason "idle" (the turn that answers your latest prompt ended and it waits for input; an idle before that turn started does not count), "needs_approval" (its card shows a prompt only the person may answer; never answer it yourself), "done" or "failed" (its process exited), "quiet" (it reports no status or no turn start and its screen stopped changing, so judge from output), "closed" (its card was closed) or "timeout" after timeoutSeconds (default ${DEFAULT_AGENT_WAIT_SECONDS}, at most ${MAX_AGENT_WAIT_SECONDS}), with status, exitCode, waitedMs, the masked terminal tail as output, and answer (the final reply of the turn that ended, for Codex and OpenCode subagents); when the subagent's process exited (for example at once, on a model its CLI does not know), exitLines holds the last lines of its screen as plain text, which say why. After a timeout, call it again. Then read get_agent_result.`, + `Wait until one of this session's subagents stops working, instead of polling observe_agent or get_agent_result; nothing is sent to it while it waits. Returns reason "idle" (the turn that answers your latest prompt ended and it waits for input; an idle before that turn started does not count), "needs_approval" (its card shows a prompt only the person may answer; never answer it yourself), "done" or "failed" (its process exited), "quiet" (it reports no status or no turn start and its screen stopped changing, so judge from output), "closed" (its card was closed) or "timeout" after timeoutSeconds (default ${DEFAULT_AGENT_WAIT_SECONDS}, at most ${MAX_AGENT_WAIT_SECONDS}), with status, exitCode, waitedMs, the end of its screen as masked plain text as output, and answer (the final reply of the turn that ended, for Codex and OpenCode subagents); when the subagent's process exited (for example at once, on a model its CLI does not know), exitLines holds the last lines of its screen as plain text, which say why. After a timeout, call it again. Then read get_agent_result.`, { sessionId, timeoutSeconds: integer({ minimum: 1, maximum: MAX_AGENT_WAIT_SECONDS }) }, ["sessionId"] ), tool( "get_agent_result", - "Get one of this session's subagents' result: answer (its last turn's final reply as the agent reported it, for Codex and OpenCode subagents; truncated:true when only the end was kept), status (idle once its turn ended), the exit state (running | done | failed; an interactive CLI stays running after a task) and the masked terminal tail as output. Prefer answer; the terminal tail is raw screen output.", + "Get one of this session's subagents' result: answer (its last turn's final reply as the agent reported it, for Codex and OpenCode subagents; truncated:true when only the end was kept), status (idle once its turn ended), the exit state (running | done | failed; an interactive CLI stays running after a task) and the end of its screen as masked plain text as output (what the terminal shows, without control sequences). Prefer answer.", { sessionId }, ["sessionId"] ), diff --git a/src/main/services/AgentControlService.ts b/src/main/services/AgentControlService.ts index e7b79d41d..c46e63d98 100644 --- a/src/main/services/AgentControlService.ts +++ b/src/main/services/AgentControlService.ts @@ -15,6 +15,7 @@ import type { TerminalManager } from "./TerminalManager.ts"; import { onDiskPath, otherSpellings } from "./onDiskPath.ts"; import { LaunchRefusal } from "./launchRefusal.ts"; import { terminalFailureDetails } from "./terminalFailureDetails.ts"; +import { renderedTerminalText } from "./renderedTerminalText.ts"; import { RESULT_CAPTURE_PROVIDERS } from "./resultCapture.ts"; // Cards one parent may have in total, live or exited (the live limit is the person's setting). @@ -30,8 +31,12 @@ export interface DelegationLimits { } const MAX_OBSERVE_CHARS = 8_192; // An exited agent's reason is in its last screen lines (OpenCode: "Error: Unexpected server error" for an unknown model). -const MAX_EXIT_WINDOW_CHARS = 16_384; const MAX_EXIT_LINES = 20; +/** + * How much of the scrollback (masked first) the plain-text output is rendered from: several full redraws of a + * full-screen TUI, and many screens of a line-based CLI, while each call still masks a bounded window. + */ +const MAX_SCREEN_SOURCE_CHARS = 65_536; const CHILD_POSITION_STEP = { x: 60, y: 60 }; export interface SpawnAgentRequest { @@ -52,7 +57,7 @@ export interface SpawnAgentRequest { export interface AgentObservation { sessionId: string; status: SessionSnapshot["status"]; - /** Raw terminal tail, capped; capabilities with result \"none\" see nothing. */ + /** The screen's text as a terminal shows it (no control sequences), masked, capped to its last characters. */ output: string; /** Once the process exited: its exit code and the last lines of its screen as plain text, masked. */ exitCode?: number | null; @@ -72,7 +77,7 @@ export interface AgentWaitResult { status?: SessionSnapshot["status"]; exitCode: number | null; waitedMs: number; - /** Masked terminal tail (masked before the cut). */ + /** The screen's text as a terminal shows it (no control sequences), masked, capped to its last characters. */ output: string; /** Once the process exited: the last lines of its screen as plain text, masked (why it stopped, e.g. a bad model). */ exitLines?: string; @@ -109,6 +114,7 @@ export interface AgentResult { /** The session's status (idle once its turn ended; state stays "running" while the CLI is open). */ status: SessionSnapshot["status"]; exitCode: number | null; + /** The screen's text as a terminal shows it (no control sequences), masked, capped to its last characters. */ output: string; /** Once the process exited: the last lines of its screen as plain text, masked. */ exitLines?: string; @@ -292,27 +298,30 @@ export class AgentControlService { return false; } - observe(sessionId: string, maxChars = MAX_OBSERVE_CHARS): AgentObservation { - const session = this.requireSession(sessionId); - if (session.provider === "terminal") throw new Error("Plain terminals are not agents."); - const capabilities = PROVIDER_CAPABILITIES[session.provider as AgentProviderId]; - if (!capabilities.observe) throw new Error(`${session.provider} cannot be observed.`); + async observe(sessionId: string, maxChars = MAX_OBSERVE_CHARS): Promise { + const session = this.observableSession(sessionId); const buffer = this.terminals.readBuffer(sessionId).buffer; - const exitLines = session.exitCode === null ? null : this.exitLines(buffer); + const exitLines = session.exitCode === null ? null : await this.screenExitLines(sessionId, buffer); const delivery = this.delivery(sessionId); return { sessionId: session.id, status: session.status, ...(delivery ? { delivery } : {}), - // Masked before the cut (a cut inside a secret would leave a tail no pattern recognizes), over a window - // wider than any match rather than the whole scrollback. - output: this.redactTail(buffer, maxChars), + output: await this.screenText(sessionId, buffer, maxChars), ...(session.exitCode === null ? {} : { exitCode: session.exitCode }), ...(exitLines ? { exitLines } : {}) }; } - result(sessionId: string): AgentResult { + private observableSession(sessionId: string): SessionMetadata { + const session = this.requireSession(sessionId); + if (session.provider === "terminal") throw new Error("Plain terminals are not agents."); + const capabilities = PROVIDER_CAPABILITIES[session.provider as AgentProviderId]; + if (!capabilities.observe) throw new Error(`${session.provider} cannot be observed.`); + return session; + } + + async result(sessionId: string): Promise { const session = this.requireSession(sessionId); if (session.provider === "terminal") throw new Error("Plain terminals are not agents."); const capabilities = PROVIDER_CAPABILITIES[session.provider as AgentProviderId]; @@ -325,7 +334,7 @@ export class AgentControlService { const buffer = capabilities.result === "terminal" ? this.terminals.readBuffer(sessionId).buffer : ""; - const exitLines = session.exitCode === null ? null : this.exitLines(buffer); + const exitLines = session.exitCode === null ? null : await this.screenExitLines(sessionId, buffer); return { sessionId: session.id, state: session.exitCode === null @@ -334,7 +343,7 @@ export class AgentControlService { status: session.status, exitCode: session.exitCode, ...(delivery ? { delivery } : {}), - output: this.redactTail(buffer, MAX_OBSERVE_CHARS), + output: buffer ? await this.screenText(sessionId, buffer, MAX_OBSERVE_CHARS) : "", ...(exitLines ? { exitLines } : {}), ...(answer ? { answer } : {}) }; @@ -354,11 +363,11 @@ export class AgentControlService { const timing = this.options.waitTiming ?? AGENT_WAIT_TIMING; const timeoutMs = Math.min(MAX_AGENT_WAIT_MS, Math.max(0, request.timeoutMs)); const started = Date.now(); - const answer = (session: SessionMetadata | null, reason: AgentWaitReason): AgentWaitResult => { + const answer = async (session: SessionMetadata | null, reason: AgentWaitReason): Promise => { const waitedMs = Date.now() - started; if (!session) return { sessionId, reason, exitCode: null, waitedMs, output: "" }; let observation: AgentObservation | null = null; - try { observation = this.observe(sessionId); } catch { observation = null; } + try { observation = await this.observe(sessionId); } catch { observation = null; } const answer = reason === "timeout" || reason === "needs_approval" ? null : this.answer(sessionId); return { sessionId, reason, status: session.status, exitCode: session.exitCode, waitedMs, output: observation?.output ?? "", ...(observation?.delivery ? { delivery: observation.delivery } : {}), @@ -404,13 +413,44 @@ export class AgentControlService { return delivery.receipt; } - /** The last lines of an exited agent's screen as plain text: masked over the whole tail window before it is cut. */ - private exitLines(buffer: string): string | null { - const text = terminalFailureDetails(this.redactTail(buffer, MAX_EXIT_WINDOW_CHARS)); + /** + * The last lines of the screen, rendered like `output` (a TUI that redraws only the cells that changed would + * otherwise read as its redraws joined together) from the same window as `output` (a line drawn early and never + * redrawn, such as the model's status line, is still on the screen), masked before it is cut and again after. + */ + private async screenExitLines(sessionId: string, buffer: string): Promise { + const source = fromSequenceBoundary(this.redactTail(buffer, MAX_SCREEN_SOURCE_CHARS), buffer.length > MAX_SCREEN_SOURCE_CHARS); + let screen: string; + try { + screen = this.redactTail(await renderedTerminalText(source, this.geometry(sessionId)), MAX_SCREEN_SOURCE_CHARS); + } catch { + screen = source; + } + const text = terminalFailureDetails(screen); if (!text) return null; return text.split("\n").slice(-MAX_EXIT_LINES).join("\n"); } + /** + * The text the agent's terminal shows, as plain text: a TUI's redraws (cursor moves, erases, colors) rendered by a + * headless terminal instead of returned as escape sequences. Masked twice: the scrollback before it is rendered + * (a held value the TUI wrote whole is gone before any redraw can split it), and the rendered text before its + * last `maxChars` are cut (a value the screen shows across wrapped rows or box sides). Should the rendering fail, + * the masked raw tail is returned as before. + */ + private async screenText(sessionId: string, buffer: string, maxChars: number): Promise { + const source = fromSequenceBoundary(this.redactTail(buffer, MAX_SCREEN_SOURCE_CHARS), buffer.length > MAX_SCREEN_SOURCE_CHARS); + try { + return this.redactTail(await renderedTerminalText(source, this.geometry(sessionId)), maxChars); + } catch { + return this.redactTail(buffer, maxChars); + } + } + + private geometry(sessionId: string): { cols: number; rows: number } | null { + try { return typeof this.terminals.geometry === "function" ? this.terminals.geometry(sessionId) : null; } catch { return null; } + } + /** Plugin launch secrets never reach another agent through observed output: the tail as masking the whole text leaves it. */ private redactTail(text: string, maxChars: number): string { if (typeof this.terminals.redactSecretsTail === "function") return this.terminals.redactSecretsTail(text, maxChars); @@ -534,6 +574,16 @@ function tail(text: string, maxChars: number): string { return text.slice(text.length - maxChars); } +/** + * A window cut from the end of a terminal stream may begin inside an escape sequence, whose rest (`18;9H`, `m`) a + * terminal would print as text. A cut window therefore starts at its first escape or line break instead. + */ +function fromSequenceBoundary(window: string, cut: boolean): string { + if (!cut) return window; + const starts = [window.indexOf("\u001b"), window.indexOf("\n")].filter((index) => index >= 0); + return starts.length > 0 ? window.slice(Math.min(...starts)) : window; +} + function spawnCanceled(): Error { return new DOMException("The spawn was canceled before its agent started.", "AbortError"); } diff --git a/src/main/services/agent-browser/OrchestrationTools.ts b/src/main/services/agent-browser/OrchestrationTools.ts index db65986fa..53119ab5c 100644 --- a/src/main/services/agent-browser/OrchestrationTools.ts +++ b/src/main/services/agent-browser/OrchestrationTools.ts @@ -66,9 +66,9 @@ export class ScopedOrchestrationHandler implements OrchestrationCommandHandler { case "send_to_agent": return await this.send(sessionId, request.arguments, signal); case "observe_agent": - return this.observe(sessionId, request.arguments); + return await this.observe(sessionId, request.arguments); case "get_agent_result": - return this.result(sessionId, request.arguments); + return await this.result(sessionId, request.arguments); case "cancel_agent": return this.cancel(sessionId, request.arguments); case "list_agents": @@ -203,9 +203,9 @@ export class ScopedOrchestrationHandler implements OrchestrationCommandHandler { return { sessionId: args.sessionId as string, sent: true, ...(delivery ? { delivery } : {}) }; } - private observe(orchestratorId: string, args: Record): Record { + private async observe(orchestratorId: string, args: Record): Promise> { this.requireOwned(orchestratorId, args.sessionId as string); - const observation = this.control.observe( + const observation = await this.control.observe( args.sessionId as string, args.maxChars as number | undefined ); @@ -218,9 +218,9 @@ export class ScopedOrchestrationHandler implements OrchestrationCommandHandler { }; } - private result(orchestratorId: string, args: Record): Record { + private async result(orchestratorId: string, args: Record): Promise> { this.requireOwned(orchestratorId, args.sessionId as string); - const result = this.control.result(args.sessionId as string); + const result = await this.control.result(args.sessionId as string); return { sessionId: result.sessionId, state: result.state, diff --git a/src/main/services/materials/sessionScreen.ts b/src/main/services/materials/sessionScreen.ts index e58b8f300..7a3436fb7 100644 --- a/src/main/services/materials/sessionScreen.ts +++ b/src/main/services/materials/sessionScreen.ts @@ -115,9 +115,18 @@ export function pasteMarkers(screen: string): number { /** Visible screen rows, optionally reconstructing xterm's soft-wrapped logical lines. */ export function terminalScreenText(terminal: import("@xterm/headless").Terminal, unwrap = false): string { + const buffer = terminal.buffer.active; + return terminalRowsText(terminal, buffer.baseY, buffer.baseY + terminal.rows, unwrap); +} + +/** + * Rows `from` to `to` (exclusive) of the active buffer as text. `unwrap` joins a row xterm wrapped onto the line it + * continues (untrimmed, so a space the wrap fell on stays between the words) and trims each line once it is whole. + */ +export function terminalRowsText(terminal: import("@xterm/headless").Terminal, from: number, to: number, unwrap = false): string { const buffer = terminal.buffer.active; const lines: string[] = []; - for (let row = buffer.baseY; row < buffer.baseY + terminal.rows; row++) { + for (let row = from; row < to; row++) { const line = buffer.getLine(row); const text = line?.translateToString(!unwrap) ?? ""; if (unwrap && line?.isWrapped && lines.length) lines[lines.length - 1] += text; diff --git a/src/main/services/renderedTerminalText.ts b/src/main/services/renderedTerminalText.ts new file mode 100644 index 000000000..bff948199 --- /dev/null +++ b/src/main/services/renderedTerminalText.ts @@ -0,0 +1,39 @@ +import { lazyRequire } from "../lazyRequire.ts"; +import { terminalRowsText } from "./materials/sessionScreen.ts"; + +const xterm = lazyRequire("@xterm/headless"); + +/** Rows of scrollback the rendering keeps above the screen: the tail an orchestrator reads is far shorter. */ +const SCROLLBACK_ROWS = 500; +const DEFAULT_GEOMETRY = { cols: 120, rows: 40 }; + +/** + * What a terminal shows for `data` as plain text: the scrollback rows and the screen (the alternate screen of a + * full-screen TUI such as OpenCode, which keeps no scrollback), as a headless xterm renders them, so cursor moves, + * redraws, erases and colors leave only the text a person sees. A row the terminal wrapped continues its line; + * trailing blanks and empty rows at the end are dropped. + */ +export async function renderedTerminalText(data: string, geometry: { cols: number; rows: number } | null): Promise { + const { cols, rows } = geometry && geometry.cols > 0 && geometry.rows > 0 ? geometry : DEFAULT_GEOMETRY; + const terminal = new (xterm().Terminal)({ cols, rows, scrollback: SCROLLBACK_ROWS, allowProposedApi: true }); + const rowsText = (): string => terminalRowsText(terminal, 0, terminal.buffer.active.length, true).replace(/\n+$/u, ""); + // A full-screen TUI leaves its alternate screen as it exits, which discards that screen: its last frame (often why it + // stopped) is kept from the moment it leaves, before what it printed afterwards. + let leftScreen = ""; + const leaving = terminal.parser.registerCsiHandler({ prefix: "?", final: "l" }, (params) => { + if (terminal.buffer.active.type === "alternate" && params.some((mode) => mode === 1049 || mode === 1047 || mode === 47)) { + leftScreen = rowsText(); + } + return false; + }); + try { + await new Promise((resolve) => terminal.write(data, resolve)); + // Every row, scrollback included (sessionScreen's terminalScreenText reads only the visible screen of a live one). + const shown = rowsText(); + if (terminal.buffer.active.type === "alternate" || !leftScreen) return shown; + return shown ? `${leftScreen}\n${shown}` : leftScreen; + } finally { + leaving.dispose(); + terminal.dispose(); + } +} diff --git a/tests/agent-control.test.mjs b/tests/agent-control.test.mjs index 0db06954a..cbccc78ed 100644 --- a/tests/agent-control.test.mjs +++ b/tests/agent-control.test.mjs @@ -868,7 +868,7 @@ test("programmatic submit uses each launch's transport and preserves prompt byte } }); -test("observe returns a capped terminal tail and result reflects exit state", () => { +test("observe returns a capped terminal tail and result reflects exit state", async () => { const { terminals, control } = serviceFixture(); const parent = terminals.create({ provider: "codex", @@ -876,11 +876,11 @@ test("observe returns a capped terminal tail and result reflects exit state", () profile: "normal", position: { x: 0, y: 0 } }); - const observation = control.observe(parent.id); + const observation = await control.observe(parent.id); assert.equal(observation.sessionId, parent.id); assert.equal(observation.output.length <= 8_192, true); - const running = control.result(parent.id); + const running = await control.result(parent.id); assert.equal(running.state, "running"); assert.equal(running.exitCode, null); terminals.disposeAll(); @@ -905,8 +905,8 @@ test("cancel disposes the subagent and plain terminals are not agents", async () assert.equal(terminals.list().some((session) => session.id === child.id), false); assert.throws(() => control.send(terminal.id, "text"), /not agents/u); - assert.throws(() => control.observe(terminal.id), /not agents/u); - assert.throws(() => control.result(terminal.id), /not agents/u); + await assert.rejects(control.observe(terminal.id), /not agents/u); + await assert.rejects(control.result(terminal.id), /not agents/u); terminals.disposeAll(); }); @@ -964,8 +964,8 @@ test("an orchestrator tool call looks its sessions up by id: no other card's scr assert.equal(control.status(child.id).id, child.id); assert.equal(control.isInSubtree(parent.id, child.id), true); assert.deepEqual(control.children(parent.id).map((session) => session.id), [child.id]); - control.observe(child.id); - control.result(child.id); + await control.observe(child.id); + await control.result(child.id); assert.throws(() => control.status("missing"), /does not exist/u); assert.deepEqual(copied, [child.id, child.id], "only the observed card's own scrollback is read, and no list() snapshot of every card"); diff --git a/tests/launch-contributors.test.mjs b/tests/launch-contributors.test.mjs index df2491a1e..8a53e7ee8 100644 --- a/tests/launch-contributors.test.mjs +++ b/tests/launch-contributors.test.mjs @@ -310,11 +310,11 @@ test("secret env comes from the plugin's own secrets, reaches the child, and is calls[0].print(`env: PROVIDER_API_KEY=${SECRET} PLAIN=visible\n`); const control = new AgentControlService(manager); - const observed = control.observe(created.id).output; + const observed = (await control.observe(created.id)).output; assert.match(observed, /PROVIDER_API_KEY= PLAIN=visible/u); assert.doesNotMatch(observed, new RegExp(SECRET, "u")); calls[0].exit(1); - assert.doesNotMatch(control.result(created.id).output, new RegExp(SECRET, "u")); + assert.doesNotMatch((await control.result(created.id)).output, new RegExp(SECRET, "u")); assert.doesNotMatch(card(manager, created.id).failureDetails ?? "", new RegExp(SECRET, "u")); await manager.shutdown(); diff --git a/tests/orchestration-providers-wait.test.mjs b/tests/orchestration-providers-wait.test.mjs index 748cc024d..4056365e9 100644 --- a/tests/orchestration-providers-wait.test.mjs +++ b/tests/orchestration-providers-wait.test.mjs @@ -307,3 +307,167 @@ test("the skill tells the orchestrator to pass a model the person names", async const skill = await readFile(new URL("../agent/orchestrator/SKILL.md", import.meta.url), "utf8"); assert.match(skill, /If the person names a model, pass it as `model`/u); }); +/** A recorded OpenCode-style session: alternate screen, absolute cursor moves, colors, a spinner and in-place redraws. */ +function openCodeRedraws() { + const e = "\u001b"; + const at = (row, column) => `${e}[${row};${column}H`; + const parts = [ + `${e}[?1049h${e}[?25l${e}[?2004h${e}]0;opencode${e}\\${e}[2J${e}[H`, + `${at(1, 1)}${e}[48;2;24;24;37m${e}[38;2;205;214;244m┃ opencode build gpt-5.5${e}[K${e}[0m`, + `${at(3, 3)}${e}[1mYou${e}[22m`, + `${at(4, 3)}${e}[38;5;252mFix the failing Button test${e}[39m`, + `${at(6, 3)}${e}[38;5;245mThinking${e}[0m` + ]; + // The spinner turns in place many times while the model works. + for (let turn = 0; turn < 120; turn++) parts.push(`${at(6, 12)}${"⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"[turn % 10]}${at(40, 1)}${e}[7m working ${turn}s ${e}[27m${e}[K`); + parts.push( + `${at(6, 1)}${e}[2K${at(6, 3)}${e}[38;2;166;227;161mThe Button test passes now.${e}[0m`, + `${at(7, 3)}Used key sk-secret-value for the request.`, + `${at(8, 3)}${e}[38;5;111m│${e}[0m src/Button.tsx ${e}[32m+3${e}[0m ${e}[31m-1${e}[0m`, + `${at(40, 1)}${e}[2K${e}[7m ctrl+p commands ${e}[27m` + ); + return parts.join(""); +} + +test("wait_for_agent and get_agent_result return a TUI's screen as plain text, not its escape sequences", async () => { + const { terminals, handler } = setup(); + const child = terminals.sessions.get("child"); + setTimeout(() => { + child.buffer = openCodeRedraws(); + child.outputOffset = child.buffer.length; + child.status = "idle"; + }, 20); + const waited = await call(handler, "wait_for_agent", { sessionId: "child", timeoutSeconds: 5 }); + const result = await call(handler, "get_agent_result", { sessionId: "child" }); + for (const output of [waited.output, result.output]) { + assert.equal(/[\u0000-\u0008\u000b-\u001f\u007f]/u.test(output), false, "no control characters or escape sequences"); + assert.deepEqual(output.split("\n"), [ + "┃ opencode build gpt-5.5", + "", + " You", + " Fix the failing Button test", + "", + " The Button test passes now.", + " Used key [redacted] for the request.", + " │ src/Button.tsx +3 -1", + ...Array.from({ length: 31 }, () => ""), + " ctrl+p commands" + ]); + assert.ok(!output.includes("sk-secret") && !output.includes("Thinking") && !output.includes("working"), "masked; only what the screen shows now"); + } +}); + +test("the plain-text output stays bounded and masked for a long line-based transcript", async () => { + const { terminals, control } = setup(); + const child = terminals.sessions.get("child"); + child.buffer = Array.from({ length: 4_000 }, (_value, index) => `\u001b[32mline ${index}\u001b[0m with key sk-secret-value\r\n`).join(""); + child.outputOffset = child.buffer.length; + child.status = "idle"; + const result = await control.result("child"); + assert.ok(result.output.length <= 8_192); + assert.ok(result.output.endsWith("line 3999 with key [redacted]"), result.output.slice(-80)); + assert.ok(!result.output.includes("\u001b") && !result.output.includes("sk-secret")); +}); + +/** Invisible SGR resets that pad a stream to an exact length: any length from 6 up is a sum of 3s and 4s. */ +function sgrPadding(length) { + const e = "\u001b"; + let fours = 0; + while ((length - 4 * fours) % 3 !== 0) fours++; + return `${e}[0m`.repeat(fours) + `${e}[m`.repeat((length - 4 * fours) / 3); +} + +test("a scrollback window that starts inside an escape sequence leaves no fragment of it on the screen", async () => { + const { terminals, handler } = setup(); + const child = terminals.sessions.get("child"); + const e = "\u001b"; + const at = (row, column) => `${e}[${row};${column}H`; + const start = "18;9Hof specialists who had internalized the machine."; + const redraws = []; + for (let turn = 0; turn < 900; turn++) redraws.push(`${at(40, 1)}${e}[7m working ${turn}s ${e}[27m${e}[K`); + const end = `${at(3, 3)}The first response was the assembler.`; + const body = start + redraws.join("") + end; + // The last 65,536 characters (the window the screen is rendered from) begin right after the ESC [ of a cursor move. + child.buffer = `${"earlier output\r\n".repeat(50)}${e}[` + body + sgrPadding(65_536 - body.length); + child.outputOffset = child.buffer.length; + child.status = "idle"; + const waited = await call(handler, "wait_for_agent", { sessionId: "child", timeoutSeconds: 5 }); + const result = await call(handler, "get_agent_result", { sessionId: "child" }); + for (const output of [waited.output, result.output]) { + assert.ok(!output.includes("18;9H"), output.slice(0, 80)); + assert.ok(output.includes(" The first response was the assembler."), output); + } +}); + +test("exitLines are the exited TUI's screen as plain text, not its redraws joined together", async () => { + const { terminals, handler } = setup(); + const child = terminals.sessions.get("child"); + const e = "\u001b"; + const at = (row, column) => `${e}[${row};${column}H`; + // Drawn once at the start and never again: still on the screen when the CLI exits, more than 16 KiB later. + const early = `${at(3, 3)}${e}[2mThinking${e}[0m${at(8, 3)}${e}[38;5;111m▣ Build · glm-5.3-flash${e}[0m`; + const turns = 1_300; + const progress = []; + for (let turn = 0; turn < turns; turn++) progress.push(`${at(2, 3)}${e}[38;2;166;227;161m${"■".repeat(turn % 7)}${"⬝".repeat(7 - (turn % 7))}${e}[0m`); + // A TUI redraws only the cells that changed: here a line written once, then its typos fixed in place. + const body = early + progress.join("") + + `${at(4, 3)}As explicit memoy address, and if a single instructon was` + + `${at(4, 19)}ry address, and if a single instruction was` + + `${at(6, 3)}${e}[31mError: the model stopped answering.${e}[0m`; + assert.ok(body.length > 16_384 && body.length < 65_000, String(body.length)); + // The rendered window (the last 65,536 characters) begins with the `m` of a color sequence whose start was cut off. + child.buffer = `${"x".repeat(100)}${e}[38;2;1;2;3` + "m" + body + sgrPadding(65_536 - 1 - body.length); + child.outputOffset = child.buffer.length; + child.exitCode = 1; + child.status = "failed"; + const waited = await call(handler, "wait_for_agent", { sessionId: "child", timeoutSeconds: 5 }); + const observed = await call(handler, "observe_agent", { sessionId: "child" }); + const result = await call(handler, "get_agent_result", { sessionId: "child" }); + assert.equal(waited.reason, "failed"); + const last = (turns - 1) % 7; + assert.deepEqual(waited.exitLines.split("\n"), [ + ` ${"■".repeat(last)}${"⬝".repeat(7 - last)}`, + " Thinking", + " As explicit memory address, and if a single instruction was", + " Error: the model stopped answering.", + " ▣ Build · glm-5.3-flash" + ]); + assert.equal(observed.exitLines, waited.exitLines); + assert.equal(result.exitLines, waited.exitLines); +}); + +test("observe_agent returns the screen as plain text too, within its maxChars", async () => { + const { terminals, handler } = setup(); + const child = terminals.sessions.get("child"); + child.buffer = openCodeRedraws(); + child.outputOffset = child.buffer.length; + child.status = "working"; + const observed = await call(handler, "observe_agent", { sessionId: "child" }); + assert.equal(/[\u0000-\u0008\u000b-\u001f\u007f]/u.test(observed.output), false, "no ESC or other control bytes"); + assert.ok(observed.output.includes(" The Button test passes now.") && observed.output.includes("[redacted]")); + assert.ok(!observed.output.includes("sk-secret") && !observed.output.includes("working")); + const short = await call(handler, "observe_agent", { sessionId: "child", maxChars: 256 }); + assert.ok(short.output.length <= 256 && short.output.endsWith(" ctrl+p commands"), short.output); +}); + +test("a TUI that left its full screen as it exited keeps its last screen in output and exitLines", async () => { + const { terminals, handler } = setup(); + const child = terminals.sessions.get("child"); + const e = "\u001b"; + const at = (row, column) => `${e}[${row};${column}H`; + // A full-screen CLI shows why it stops on its own screen, then leaves the alternate screen as every TUI does on exit. + child.buffer = `${e}[?1049h${e}[2J${at(1, 1)}┃ opencode build glm-5.3-flash${at(6, 3)}${e}[31mError: Unexpected server error${e}[0m` + + `${e}[?25h${e}[?1049l` + "Session ended.\r\n"; + child.outputOffset = child.buffer.length; + child.exitCode = 1; + child.status = "failed"; + const waited = await call(handler, "wait_for_agent", { sessionId: "child", timeoutSeconds: 5 }); + const result = await call(handler, "get_agent_result", { sessionId: "child" }); + for (const answer of [waited, result]) { + assert.match(answer.exitLines, /Error: Unexpected server error/u); + assert.match(answer.exitLines, /Session ended\./u); + assert.ok(answer.exitLines.indexOf("Error: Unexpected") < answer.exitLines.indexOf("Session ended."), answer.exitLines); + assert.match(answer.output, /Error: Unexpected server error/u); + assert.equal(/[\u0000-\u0008\u000b-\u001f\u007f]/u.test(answer.output), false); + } +}); diff --git a/tests/secret-redaction.test.mjs b/tests/secret-redaction.test.mjs index db040086b..e42568757 100644 --- a/tests/secret-redaction.test.mjs +++ b/tests/secret-redaction.test.mjs @@ -164,7 +164,7 @@ test("the vault hands every value it reads or writes to the registry; agent-read lastPromptDelivery: () => undefined, redactSecrets: (text) => manager.redactSecrets(text) }); - const observed = control.observe("s1").output; + const observed = (await control.observe("s1")).output; assert.equal(observed.includes(value) || observed.includes(secrets.github), false); assert.match(observed, /env OPENAI=/u); }); @@ -205,17 +205,17 @@ test("observe_agent and get_agent_result mask the whole buffer before cutting th const control = new AgentControlService(f.terminals); // The 300-character observation starts 12 characters before the secret ends. f.print(`${"a".repeat(1_000)}${PLAIN_SECRET}${"b".repeat(288)}`); - const observed = control.observe(f.card.id, 300).output; + const observed = (await control.observe(f.card.id, 300)).output; assert.ok(observed.length <= 300, "the bound is kept"); assert.deepEqual(fragments(observed), []); // get_agent_result's 8 192-character tail, cut inside the secret the same way. f.print(`${PLAIN_SECRET}${"c".repeat(8_192 - 10)}`); - const result = control.result(f.card.id).output; + const result = (await control.result(f.card.id)).output; assert.ok(result.length <= 8_192); assert.deepEqual(fragments(result), []); // Wrapped secrets are still masked whole. f.print(`\r\n${PLAIN_SECRET.slice(0, 15)}\r\n${PLAIN_SECRET.slice(15)}\r\n`); - assert.deepEqual(fragments(control.observe(f.card.id, 8_192).output), []); + assert.deepEqual(fragments((await control.observe(f.card.id, 8_192)).output), []); }); test("plugin screen text and failure details mask the whole buffer before cutting", async (t) => { @@ -319,8 +319,8 @@ test("observe_agent, get_agent_result and the plugin screen mask a bounded windo f.print(scrollback(250_000)); f.print(`${PLAIN_SECRET}${"g".repeat(200)}`); const control = new AgentControlService(f.terminals); - assert.deepEqual(fragments(control.observe(f.card.id).output), []); - assert.deepEqual(fragments(control.result(f.card.id).output), []); + assert.deepEqual(fragments((await control.observe(f.card.id)).output), []); + assert.deepEqual(fragments((await control.result(f.card.id)).output), []); const { PluginSessions } = await import("../src/main/services/PluginSessions.ts"); const screens = []; const sessions = new PluginSessions({ terminals: f.terminals, notify: (_p, _s, _m, event) => { if (event.screen !== undefined) screens.push(event.screen); return true; } }); @@ -331,7 +331,8 @@ test("observe_agent, get_agent_result and the plugin screen mask a bounded windo await new Promise((resolve) => setTimeout(resolve, 20)); assert.ok(screens.length > 0); assert.deepEqual(fragments(screens.join("\n")), []); - assert.ok(masked.length >= 3 && Math.max(...masked) < 40_000, `masked ${masked} characters per call`); + // get_agent_result renders its plain text from a 64 KiB masked window (plus the tail margin); never the whole scrollback. + assert.ok(masked.length >= 3 && Math.max(...masked) < 90_000, `masked ${masked} characters per call`); }); /** From 291de9145e24d86f3ad1f01e6e1c215b7f1f6442 Mon Sep 17 00:00:00 2001 From: BIackFIame <77388790+BIackFIame@users.noreply.github.com> Date: Sun, 1 Nov 2026 12:27:34 +0000 Subject: [PATCH 2/2] fix(orchestration): keep plain text at a cut window and the frame in order A window cut from the end of the scrollback started at its first escape or line break. For one newline-terminated line longer than the window (minified JSON, no escape) that left only the final "\n", so all three tools returned empty output. The window is now masked with 4 KiB of what precedes it, and starts earlier only when an escape sequence begun before the cut is still open there: at that sequence's escape, so the terminal reads it whole. Ordinary text at the cut is kept. When more than the output cap of normal-buffer history preceded the alternate screen, leaving it restored that history, and the saved last frame was placed before it, so the caps on output and exitLines dropped the frame and its error. A marker now records where the history ended as the TUI entered its alternate screen; the result is the history, then the saved frame, then what was printed after leaving. --- src/main/services/AgentControlService.ts | 58 ++++++++++++++++++--- src/main/services/renderedTerminalText.ts | 29 ++++++++--- tests/orchestration-providers-wait.test.mjs | 43 +++++++++++++++ 3 files changed, 114 insertions(+), 16 deletions(-) diff --git a/src/main/services/AgentControlService.ts b/src/main/services/AgentControlService.ts index c46e63d98..0b498cd11 100644 --- a/src/main/services/AgentControlService.ts +++ b/src/main/services/AgentControlService.ts @@ -37,6 +37,8 @@ const MAX_EXIT_LINES = 20; * full-screen TUI, and many screens of a line-based CLI, while each call still masks a bounded window. */ const MAX_SCREEN_SOURCE_CHARS = 65_536; +/** How far before the window's start a control sequence it cuts may begin (an OSC title or hyperlink included). */ +const SEQUENCE_LOOKBACK_CHARS = 4_096; const CHILD_POSITION_STEP = { x: 60, y: 60 }; export interface SpawnAgentRequest { @@ -419,7 +421,7 @@ export class AgentControlService { * redrawn, such as the model's status line, is still on the screen), masked before it is cut and again after. */ private async screenExitLines(sessionId: string, buffer: string): Promise { - const source = fromSequenceBoundary(this.redactTail(buffer, MAX_SCREEN_SOURCE_CHARS), buffer.length > MAX_SCREEN_SOURCE_CHARS); + const source = this.screenSource(buffer); let screen: string; try { screen = this.redactTail(await renderedTerminalText(source, this.geometry(sessionId)), MAX_SCREEN_SOURCE_CHARS); @@ -439,7 +441,7 @@ export class AgentControlService { * the masked raw tail is returned as before. */ private async screenText(sessionId: string, buffer: string, maxChars: number): Promise { - const source = fromSequenceBoundary(this.redactTail(buffer, MAX_SCREEN_SOURCE_CHARS), buffer.length > MAX_SCREEN_SOURCE_CHARS); + const source = this.screenSource(buffer); try { return this.redactTail(await renderedTerminalText(source, this.geometry(sessionId)), maxChars); } catch { @@ -447,6 +449,16 @@ export class AgentControlService { } } + /** + * The masked window the screen is rendered from: the last MAX_SCREEN_SOURCE_CHARS, masked with a little of what + * precedes them so a control sequence the window's start cuts is included whole. + */ + private screenSource(buffer: string): string { + if (buffer.length <= MAX_SCREEN_SOURCE_CHARS) return this.redactTail(buffer, MAX_SCREEN_SOURCE_CHARS); + const masked = this.redactTail(buffer, MAX_SCREEN_SOURCE_CHARS + SEQUENCE_LOOKBACK_CHARS); + return masked.slice(windowStart(masked, Math.max(0, masked.length - MAX_SCREEN_SOURCE_CHARS))); + } + private geometry(sessionId: string): { cols: number; rows: number } | null { try { return typeof this.terminals.geometry === "function" ? this.terminals.geometry(sessionId) : null; } catch { return null; } } @@ -575,13 +587,43 @@ function tail(text: string, maxChars: number): string { } /** - * A window cut from the end of a terminal stream may begin inside an escape sequence, whose rest (`18;9H`, `m`) a - * terminal would print as text. A cut window therefore starts at its first escape or line break instead. + * Where a window cut at `cut` from the end of a terminal stream should start: at `cut`, unless an escape sequence + * begun before it is still open there (its rest, such as `18;9H` or `m`, a terminal would print as text); then at + * that sequence's escape, so the terminal reads it whole. Ordinary text at the cut is kept. Only the last escape + * before the cut can still be open. */ -function fromSequenceBoundary(window: string, cut: boolean): string { - if (!cut) return window; - const starts = [window.indexOf("\u001b"), window.indexOf("\n")].filter((index) => index >= 0); - return starts.length > 0 ? window.slice(Math.min(...starts)) : window; +function windowStart(text: string, cut: number): number { + const escape = text.lastIndexOf("\u001b", cut - 1); + if (escape < 0) return cut; + return sequenceEnd(text, escape) > cut ? escape : cut; +} + +/** The index just after the escape sequence starting at `start` (text.length when it is still unterminated). */ +function sequenceEnd(text: string, start: number): number { + const kind = text[start + 1]; + if (kind === undefined) return text.length; + if (kind === "[") { + for (let index = start + 2; index < text.length; index++) { + const code = text.charCodeAt(index); + if (code >= 0x40 && code <= 0x7e) return index + 1; + if (code === 0x1b || code === 0x18 || code === 0x1a) return index; + } + return text.length; + } + if ("]PX^_".includes(kind)) { + // OSC, DCS, SOS, PM, APC: a string ended by BEL (OSC) or ST (ESC \\). + for (let index = start + 2; index < text.length; index++) { + const code = text.charCodeAt(index); + if (code === 0x07 && kind === "]") return index + 1; + if (code === 0x1b) return text[index + 1] === "\\" ? index + 2 : index; + if (code === 0x18 || code === 0x1a) return index; + } + return text.length; + } + // ESC, its intermediates (0x20-0x2f), then one final character. + let index = start + 1; + while (index < text.length && text.charCodeAt(index) >= 0x20 && text.charCodeAt(index) <= 0x2f) index++; + return Math.min(index + 1, text.length); } function spawnCanceled(): Error { diff --git a/src/main/services/renderedTerminalText.ts b/src/main/services/renderedTerminalText.ts index bff948199..cf5276734 100644 --- a/src/main/services/renderedTerminalText.ts +++ b/src/main/services/renderedTerminalText.ts @@ -16,24 +16,37 @@ const DEFAULT_GEOMETRY = { cols: 120, rows: 40 }; export async function renderedTerminalText(data: string, geometry: { cols: number; rows: number } | null): Promise { const { cols, rows } = geometry && geometry.cols > 0 && geometry.rows > 0 ? geometry : DEFAULT_GEOMETRY; const terminal = new (xterm().Terminal)({ cols, rows, scrollback: SCROLLBACK_ROWS, allowProposedApi: true }); - const rowsText = (): string => terminalRowsText(terminal, 0, terminal.buffer.active.length, true).replace(/\n+$/u, ""); + const rowsText = (from = 0, to = terminal.buffer.active.length): string => + terminalRowsText(terminal, from, to, true).replace(/\n+$/u, ""); + const fullScreen = (params: (number | number[])[]): boolean => params.some((mode) => mode === 1049 || mode === 1047 || mode === 47); + // Where the normal buffer's history ended as a full-screen TUI entered its alternate screen: what is above stays + // above, what the CLI prints after leaving starts there (1049 restores the cursor to it). + let entered: import("@xterm/headless").IMarker | undefined; + const entering = terminal.parser.registerCsiHandler({ prefix: "?", final: "h" }, (params) => { + if (terminal.buffer.active.type === "normal" && fullScreen(params)) { + entered?.dispose(); + entered = terminal.registerMarker(0); + } + return false; + }); // A full-screen TUI leaves its alternate screen as it exits, which discards that screen: its last frame (often why it - // stopped) is kept from the moment it leaves, before what it printed afterwards. + // stopped) is kept from the moment it leaves, and placed between the history before it and what it printed afterwards. let leftScreen = ""; const leaving = terminal.parser.registerCsiHandler({ prefix: "?", final: "l" }, (params) => { - if (terminal.buffer.active.type === "alternate" && params.some((mode) => mode === 1049 || mode === 1047 || mode === 47)) { - leftScreen = rowsText(); - } + if (terminal.buffer.active.type === "alternate" && fullScreen(params)) leftScreen = rowsText(); return false; }); try { await new Promise((resolve) => terminal.write(data, resolve)); // Every row, scrollback included (sessionScreen's terminalScreenText reads only the visible screen of a live one). - const shown = rowsText(); - if (terminal.buffer.active.type === "alternate" || !leftScreen) return shown; - return shown ? `${leftScreen}\n${shown}` : leftScreen; + if (terminal.buffer.active.type === "alternate" || !leftScreen) return rowsText(); + // A marker whose row was trimmed from the scrollback has line -1: then all that is left followed the frame. + const split = entered && !entered.isDisposed && entered.line >= 0 ? entered.line : 0; + return [rowsText(0, split), leftScreen, rowsText(split)].filter(Boolean).join("\n"); } finally { leaving.dispose(); + entering.dispose(); + entered?.dispose(); terminal.dispose(); } } diff --git a/tests/orchestration-providers-wait.test.mjs b/tests/orchestration-providers-wait.test.mjs index 4056365e9..c7856f064 100644 --- a/tests/orchestration-providers-wait.test.mjs +++ b/tests/orchestration-providers-wait.test.mjs @@ -471,3 +471,46 @@ test("a TUI that left its full screen as it exited keeps its last screen in outp assert.equal(/[\u0000-\u0008\u000b-\u001f\u007f]/u.test(answer.output), false); } }); + +test("a cut window of ordinary text keeps its text: only a control sequence crossing the cut is dropped", async () => { + const { terminals, handler } = setup(); + const child = terminals.sessions.get("child"); + // One newline-terminated line longer than the rendered window (minified JSON): no escape and no line break before its end. + const items = Array.from({ length: 6_000 }, (_value, index) => `{"id":${index},"ok":true}`).join(","); + const line = `{"items":[${items}]}`; + assert.ok(line.length > 70_000, String(line.length)); + child.buffer = `${line}\r\n`; + child.outputOffset = child.buffer.length; + child.status = "idle"; + const waited = await call(handler, "wait_for_agent", { sessionId: "child", timeoutSeconds: 5 }); + const observed = await call(handler, "observe_agent", { sessionId: "child" }); + const result = await call(handler, "get_agent_result", { sessionId: "child" }); + for (const output of [waited.output, observed.output, result.output]) { + assert.equal(output.length, 8_192); + assert.ok(line.endsWith(output), output.slice(-80)); + } +}); + +test("leaving the full screen after long history keeps the order: history, the last frame, then what followed", async () => { + const { terminals, handler } = setup(); + const child = terminals.sessions.get("child"); + const e = "\u001b"; + const at = (row, column) => `${e}[${row};${column}H`; + const history = Array.from({ length: 200 }, (_value, index) => `startup log ${index} ${"-".repeat(40)}\r\n`).join(""); + assert.ok(history.length > 8_192, String(history.length)); + child.buffer = history + `${e}[?1049h${e}[2J${at(1, 1)}┃ opencode build glm-5.3-flash${at(6, 3)}${e}[31mError: Unexpected server error${e}[0m` + + `${e}[?25h${e}[?1049l` + "Session ended.\r\n"; + child.outputOffset = child.buffer.length; + child.exitCode = 1; + child.status = "failed"; + const waited = await call(handler, "wait_for_agent", { sessionId: "child", timeoutSeconds: 5 }); + const result = await call(handler, "get_agent_result", { sessionId: "child" }); + for (const answer of [waited, result]) { + for (const text of [answer.output, answer.exitLines]) { + assert.match(text, /Error: Unexpected server error/u); + assert.ok(text.endsWith("Session ended."), text.slice(-120)); + assert.ok(text.indexOf("Error: Unexpected") < text.indexOf("Session ended."), text.slice(-400)); + } + assert.ok(answer.output.indexOf("startup log 199") < answer.output.indexOf("┃ opencode"), answer.output.slice(-600)); + } +});