mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-23 01:32:40 +00:00
Send MCP terminal keys in the running program’s input mode (#7343)
* fix(server): 正确编码 MCP 终端按键 send_terminal_keys 会将方向键、导航键和多数修饰键组合当作普通文字输入。 复用协议层编码器,并从终端前导序列恢复输入模式,保留 literal 和未知文本的原样发送行为。 补充 MCP 入口回归与真实 PTY 字节验证;MCP 文件 197 项测试通过, 最终终端回归 44 项通过,服务端类型、Lint 和格式检查通过。 Fixes #7337 * 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> --------- Co-authored-by: Aling33 <3489986145@qq.com> Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
Aling33
parent
559c0200e0
commit
f0acc2c72b
@@ -64,7 +64,8 @@ import { createGitMutationService } from "../session/git-mutation/git-mutation-s
|
||||
import type { GeneratedWorkspaceName } from "../worktree-branch-name-generator.js";
|
||||
import type { ForgeService } from "../../services/forge-service.js";
|
||||
import { areEquivalentPaths } from "../../utils/path.js";
|
||||
import type { TerminalManager } from "../../terminal/terminal-manager.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";
|
||||
@@ -1251,6 +1252,168 @@ describe("browser MCP tools", () => {
|
||||
describe("terminal MCP tools", () => {
|
||||
const logger = createTestLogger();
|
||||
|
||||
it.each<[string, string, string, boolean]>([
|
||||
["Down", "\x1b[B", "", false],
|
||||
["Up", "\x1b[A", "", false],
|
||||
["Left", "\x1b[D", "", false],
|
||||
["Right", "\x1b[C", "", false],
|
||||
["PageUp", "\x1b[5~", "", false],
|
||||
["PageDown", "\x1b[6~", "", false],
|
||||
["Home", "\x1b[H", "", false],
|
||||
["End", "\x1b[F", "", false],
|
||||
["Insert", "\x1b[2~", "", false],
|
||||
["Delete", "\x1b[3~", "", false],
|
||||
["F1", "\x1bOP", "", false],
|
||||
["F5", "\x1b[15~", "", false],
|
||||
["F12", "\x1b[24~", "", false],
|
||||
["S-Tab", "\x1b[Z", "", false],
|
||||
["C-r", "\x12", "", false],
|
||||
["M-b", "\x1bb", "", false],
|
||||
["C-M-Left", "\x1b[1;7D", "", false],
|
||||
["C-S-Up", "\x1b[1;6A", "", false],
|
||||
["C-Space", "\x00", "", false],
|
||||
["Enter", "\r", "", false],
|
||||
["Tab", "\t", "", false],
|
||||
["Escape", "\x1b", "", false],
|
||||
["Space", " ", "", false],
|
||||
["BSpace", "\x7f", "", false],
|
||||
["C-c", "\x03", "", false],
|
||||
["C-d", "\x04", "", false],
|
||||
["C-z", "\x1a", "", false],
|
||||
["C-l", "\x0c", "", false],
|
||||
["C-a", "\x01", "", false],
|
||||
["C-e", "\x05", "", false],
|
||||
["Down", "\x1bOB", "\x1b[?1h", false],
|
||||
["S-Enter", "\x1b[13;2u", "\x1b[=1;1u", false],
|
||||
["C-Enter", "\x1b[13;28;13;1;8;1_", "\x1b[?9001h", false],
|
||||
["echo hello\r", "echo hello\r", "", false],
|
||||
["\x1b[B", "\x1b[B", "", false],
|
||||
["UnknownKey", "UnknownKey", "", false],
|
||||
["constructor", "constructor", "", false],
|
||||
["__proto__", "__proto__", "", false],
|
||||
["", "", "", false],
|
||||
["Down", "Down", "\x1b[?1h", true],
|
||||
["C-r", "C-r", "", true],
|
||||
["S-Tab", "S-Tab", "", true],
|
||||
])(
|
||||
"sends terminal token %j as %j (preamble=%j, literal=%s)",
|
||||
async (keys, data, preamble, literal) => {
|
||||
const { agentManager, agentStorage } = createTestDeps();
|
||||
const send = vi.fn();
|
||||
const terminalManager = createTerminalManagerStub({
|
||||
getTerminal: vi.fn().mockReturnValue({
|
||||
send,
|
||||
getReplayPreamble: () => preamble,
|
||||
}),
|
||||
});
|
||||
const server = await createAgentMcpServer({
|
||||
agentManager,
|
||||
agentStorage,
|
||||
providerSnapshotManager: createClaudeOnlyManager(),
|
||||
terminalManager,
|
||||
logger,
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await registeredTool(server, "send_terminal_keys").handler({
|
||||
terminalId: "term-1",
|
||||
keys,
|
||||
literal,
|
||||
});
|
||||
expect(response.structuredContent).toEqual({ success: true });
|
||||
expect(send).toHaveBeenCalledExactlyOnceWith({ type: "input", data });
|
||||
} finally {
|
||||
await server.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
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:1b5b42");
|
||||
});
|
||||
} finally {
|
||||
unsubscribe();
|
||||
await server.close();
|
||||
await terminalManager.killTerminalAndWait(terminal.id);
|
||||
terminalManager.killAll();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("captures terminal output through the terminal manager authority", async () => {
|
||||
const { agentManager, agentStorage } = createTestDeps();
|
||||
const captureTerminal = vi.fn().mockResolvedValue({
|
||||
|
||||
@@ -39,6 +39,8 @@ import type { FirstAgentContext } from "../../messages.js";
|
||||
import { everyMsToFiveFieldCron } from "@getpaseo/protocol/schedule/cadence";
|
||||
import { expandUserPath, isSameOrDescendantPath, resolvePathFromBase } from "../../path-utils.js";
|
||||
import type { TerminalManager } from "../../../terminal/terminal-manager.js";
|
||||
import { encodeTerminalKeyInput } from "@getpaseo/protocol/terminal-key-input";
|
||||
import { TerminalInputModeTracker } from "@getpaseo/protocol/terminal-input-mode";
|
||||
import type { CreatePaseoWorktreeWorkflowFn } from "../../worktree-session.js";
|
||||
import { computeNextRunAt, validateScheduleCadence } from "../../schedule/cron.js";
|
||||
import {
|
||||
@@ -594,37 +596,44 @@ const TerminalSummarySchema = z.object({
|
||||
cwd: z.string(),
|
||||
});
|
||||
|
||||
function resolveTerminalKeyToken(key: string, literal: boolean): string {
|
||||
interface TerminalKeyTokenInput {
|
||||
key: string;
|
||||
literal: boolean;
|
||||
preamble: string;
|
||||
}
|
||||
|
||||
function resolveTerminalKeyToken({ key, literal, preamble }: TerminalKeyTokenInput): string {
|
||||
if (literal) {
|
||||
return key;
|
||||
}
|
||||
|
||||
switch (key) {
|
||||
case "Enter":
|
||||
return "\r";
|
||||
case "Tab":
|
||||
return "\t";
|
||||
case "Escape":
|
||||
return "\u001b";
|
||||
case "Space":
|
||||
return " ";
|
||||
case "BSpace":
|
||||
return "\u007f";
|
||||
case "C-c":
|
||||
return "\u0003";
|
||||
case "C-d":
|
||||
return "\u0004";
|
||||
case "C-z":
|
||||
return "\u001a";
|
||||
case "C-l":
|
||||
return "\u000c";
|
||||
case "C-a":
|
||||
return "\u0001";
|
||||
case "C-e":
|
||||
return "\u0005";
|
||||
default:
|
||||
return key;
|
||||
const match = /^((?:[CMS]-)*)(.+)$/.exec(key);
|
||||
if (!match) {
|
||||
return key;
|
||||
}
|
||||
const [, modifiers, token] = match;
|
||||
const aliases: Record<string, string> = {
|
||||
Up: "ArrowUp",
|
||||
Down: "ArrowDown",
|
||||
Left: "ArrowLeft",
|
||||
Right: "ArrowRight",
|
||||
BSpace: "Backspace",
|
||||
Space: " ",
|
||||
};
|
||||
const inputMode = new TerminalInputModeTracker();
|
||||
inputMode.feed(preamble);
|
||||
const encodedKey = Object.hasOwn(aliases, token) ? aliases[token] : token;
|
||||
return (
|
||||
encodeTerminalKeyInput(
|
||||
{
|
||||
key: encodedKey,
|
||||
ctrl: modifiers.includes("C-"),
|
||||
alt: modifiers.includes("M-"),
|
||||
shift: modifiers.includes("S-"),
|
||||
},
|
||||
{ inputMode: inputMode.getState() },
|
||||
) || key
|
||||
);
|
||||
}
|
||||
|
||||
export function createPaseoToolCatalog(options: PaseoToolHostDependencies): PaseoToolCatalog {
|
||||
@@ -2591,7 +2600,8 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
|
||||
"send_terminal_keys",
|
||||
{
|
||||
title: "Send terminal keys",
|
||||
description: "Send literal text or special key tokens to a terminal session.",
|
||||
description:
|
||||
"Send text or a key token: Enter, Tab, Escape, Space, BSpace, Up, Down, Left, Right, Home, End, Insert, Delete, PageUp, PageDown, or F1-F12. Combine C- (Ctrl), M- (Alt), and S- (Shift) prefixes, e.g. C-r, S-Tab, or C-M-Left. Set literal=true to send the text unchanged.",
|
||||
inputSchema: {
|
||||
terminalId: z.string(),
|
||||
keys: z.string(),
|
||||
@@ -2613,7 +2623,11 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
|
||||
|
||||
terminal.send({
|
||||
type: "input",
|
||||
data: resolveTerminalKeyToken(keys, literal),
|
||||
data: resolveTerminalKeyToken({
|
||||
key: keys,
|
||||
literal,
|
||||
preamble: terminal.getReplayPreamble(),
|
||||
}),
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -121,6 +121,7 @@ export type TerminalWorkerEvent =
|
||||
type: "terminalMessage";
|
||||
terminalId: string;
|
||||
message: ServerMessage;
|
||||
replayPreamble: string;
|
||||
}
|
||||
| {
|
||||
type: "terminalExit";
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user