From 656967d51367cd0bcd697733cb1445fbf378e446 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Sun, 18 Oct 2026 23:23:51 +0200 Subject: [PATCH] fix(terminal): keep MCP keys in the running program input mode The worker mirror refreshed its input preamble only on snapshot reads, so MCP arrows used normal mode after an unattached program enabled application cursor keys. Carry the authoritative preamble with streamed worker messages before notifying consumers. Co-authored-by: Aling33 <3489986145@qq.com> --- .../src/server/agent/mcp-server.test.ts | 121 +++++++++++------- .../src/terminal/terminal-worker-process.ts | 2 + .../src/terminal/terminal-worker-protocol.ts | 1 + .../src/terminal/worker-terminal-manager.ts | 12 +- 4 files changed, 83 insertions(+), 53 deletions(-) diff --git a/packages/server/src/server/agent/mcp-server.test.ts b/packages/server/src/server/agent/mcp-server.test.ts index 2b81ae8275..6e093a18a4 100644 --- a/packages/server/src/server/agent/mcp-server.test.ts +++ b/packages/server/src/server/agent/mcp-server.test.ts @@ -65,6 +65,7 @@ import type { GeneratedWorkspaceName } from "../worktree-branch-name-generator.j import type { ForgeService } from "../../services/forge-service.js"; import { areEquivalentPaths } from "../../utils/path.js"; import { createTerminalManager, type TerminalManager } from "../../terminal/terminal-manager.js"; +import { createWorkerTerminalManager } from "../../terminal/worker-terminal-manager.js"; import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels"; import { PASEO_TOOL_NAMES } from "@getpaseo/protocol/paseo-tool-presentation/index"; import { MutableDaemonConfigSchema, type AgentProfile } from "@getpaseo/protocol/messages"; @@ -1327,57 +1328,91 @@ describe("terminal MCP tools", () => { }, ); - it("delivers MCP key tokens to a real terminal in application cursor mode", async () => { - const { agentManager, agentStorage } = createTestDeps(); - const terminalManager = createTerminalManager(); - const terminal = await terminalManager.createTerminal({ - cwd: process.cwd(), - workspaceId: "ws-terminal-keys", - command: process.execPath, - args: [ - "-e", - `process.stdin.setRawMode(true); - process.stdout.write("\\x1b[?1hREADY\\r\\n"); + it.each([ + ["in-process", createTerminalManager], + ["worker", createWorkerTerminalManager], + ] as const)( + "delivers MCP key tokens through %s after terminal input modes change", + async (_name, createManager) => { + const { agentManager, agentStorage } = createTestDeps(); + const terminalManager = createManager(); + const terminal = await terminalManager.createTerminal({ + cwd: process.cwd(), + workspaceId: "ws-terminal-keys", + command: process.execPath, + args: [ + "-e", + `process.stdin.setRawMode(true); + process.stdout.write("READY\\r\\n"); process.stdin.on("data", data => { + if (data.toString() === "application") { + process.stdout.write("\\x1b[?1hAPPLICATION_READY\\r\\n"); + return; + } + if (data.toString() === "normal") { + process.stdout.write("\\x1b[?1lNORMAL_READY\\r\\n"); + return; + } process.stdout.write("HEX:" + data.toString("hex") + "\\r\\n"); });`, - ], - }); - const server = await createAgentMcpServer({ - agentManager, - agentStorage, - providerSnapshotManager: createClaudeOnlyManager(), - terminalManager, - logger, - }); + ], + }); + const server = await createAgentMcpServer({ + agentManager, + agentStorage, + providerSnapshotManager: createClaudeOnlyManager(), + terminalManager, + logger, + }); - try { - await vi.waitFor( - async () => { - const capture = await terminalManager.captureTerminal(terminal.id); - expect(capture.lines).toContain("READY"); - }, - { timeout: 5000 }, - ); - const tool = registeredTool(server, "send_terminal_keys"); - for (const [keys, hex] of [ - ["Down", "1b4f42"], - ["PageDown", "1b5b367e"], - ["S-Tab", "1b5b5a"], - ["C-r", "12"], - ["M-b", "1b62"], - ]) { - await tool.handler({ terminalId: terminal.id, keys }); + let output = ""; + const unsubscribe = terminal.subscribe((message) => { + if (message.type === "output") { + output += message.data; + } + }); + try { + await vi.waitFor( + async () => { + const capture = await terminalManager.captureTerminal(terminal.id); + expect(capture.lines).toContain("READY"); + }, + { timeout: 5000 }, + ); + const tool = registeredTool(server, "send_terminal_keys"); + await tool.handler({ terminalId: terminal.id, keys: "application", literal: true }); + await vi.waitFor(() => expect(output).toContain("APPLICATION_READY")); + for (const [keys, hex] of [ + ["Down", "1b4f42"], + ["Up", "1b4f41"], + ["Left", "1b4f44"], + ["Right", "1b4f43"], + ["PageDown", "1b5b367e"], + ["S-Tab", "1b5b5a"], + ["C-r", "12"], + ["M-b", "1b62"], + ]) { + await tool.handler({ terminalId: terminal.id, keys }); + await vi.waitFor(async () => { + const capture = await terminalManager.captureTerminal(terminal.id); + expect(capture.lines.join("\n")).toContain(`HEX:${hex}`); + }); + } + await tool.handler({ terminalId: terminal.id, keys: "normal", literal: true }); + await vi.waitFor(() => expect(output).toContain("NORMAL_READY")); + await tool.handler({ terminalId: terminal.id, keys: "Down" }); await vi.waitFor(async () => { const capture = await terminalManager.captureTerminal(terminal.id); - expect(capture.lines).toContain(`HEX:${hex}`); + expect(capture.lines).toContain("HEX:1b5b42"); }); + } finally { + unsubscribe(); + await server.close(); + await terminalManager.killTerminalAndWait(terminal.id); + terminalManager.killAll(); } - } finally { - await server.close(); - await terminalManager.killTerminalAndWait(terminal.id); - } - }); + }, + ); it("captures terminal output through the terminal manager authority", async () => { const { agentManager, agentStorage } = createTestDeps(); diff --git a/packages/server/src/terminal/terminal-worker-process.ts b/packages/server/src/terminal/terminal-worker-process.ts index 4e16f8de04..a70d37c5b9 100644 --- a/packages/server/src/terminal/terminal-worker-process.ts +++ b/packages/server/src/terminal/terminal-worker-process.ts @@ -125,6 +125,7 @@ function watchTerminal(session: TerminalSession): void { type: "terminalMessage", terminalId: session.id, message: { type: "output", data: payload.toString("utf8"), revision }, + replayPreamble: session.getReplayPreamble(), }); }, }); @@ -144,6 +145,7 @@ function watchTerminal(session: TerminalSession): void { type: "terminalMessage", terminalId: session.id, message, + replayPreamble: session.getReplayPreamble(), }); }, // Creation already sends an authoritative state in terminalCreated. A second diff --git a/packages/server/src/terminal/terminal-worker-protocol.ts b/packages/server/src/terminal/terminal-worker-protocol.ts index 6b722c978a..5e7d1a30c0 100644 --- a/packages/server/src/terminal/terminal-worker-protocol.ts +++ b/packages/server/src/terminal/terminal-worker-protocol.ts @@ -121,6 +121,7 @@ export type TerminalWorkerEvent = type: "terminalMessage"; terminalId: string; message: ServerMessage; + replayPreamble: string; } | { type: "terminalExit"; diff --git a/packages/server/src/terminal/worker-terminal-manager.ts b/packages/server/src/terminal/worker-terminal-manager.ts index ca84cfe622..0a1adb2c44 100644 --- a/packages/server/src/terminal/worker-terminal-manager.ts +++ b/packages/server/src/terminal/worker-terminal-manager.ts @@ -67,8 +67,7 @@ interface WorkerTerminalRecord { state: TerminalState; activity: TerminalActivity | null; // Cached input-mode preamble from the worker (the authoritative tracker lives - // in the worker process). Refreshed on every getTerminalState response and on - // the snapshotReady event that precedes a live-restore replay. + // in the worker process). Updated with streamed output and snapshot responses. replayPreamble: string; exitInfo: TerminalExitInfo | null; messageListeners: Set<(msg: ServerMessage) => void>; @@ -356,11 +355,6 @@ export function createWorkerTerminalManager( }; }, getReplayPreamble(): string { - // Refreshed from every getTerminalState response, which the controller fetches - // before every snapshot replay (legacy + visible-snapshot restore). The one - // gap is restore.mode === "live", which replays without fetching state — there - // this can be stale/empty. No client sends "live" today; revisit (ship the - // preamble on the worker's snapshotReady) if one ever does. return record.replayPreamble; }, getTitle(): string | undefined { @@ -429,9 +423,7 @@ export function createWorkerTerminalManager( if (message.message.type === "snapshot") { record.state = message.message.state; } - if (message.message.type === "snapshotReady" && message.message.replayPreamble !== undefined) { - record.replayPreamble = message.message.replayPreamble; - } + record.replayPreamble = message.replayPreamble; for (const listener of Array.from(record.messageListeners)) { listener(message.message); }