Files
paseo/packages/server/src/terminal/terminal-session-controller.test.ts
T
Mohamed BoudraandGitHub c8ca503cc3 Show agent terminals created in workspace subdirectories (#1245)
Terminals were bucketed and looked up by exact cwd, so a terminal an
agent opened in a subdirectory of the workspace never appeared in the
workspace panel and its live updates were dropped — the panel only ever
queried and subscribed the exact root.

getTerminals(root) now aggregates every bucket at or below the root, and
the session controller delivers a subdirectory change to each subscribed
root above it, keyed by that root and carrying the full aggregated list.
Keying by the root with the complete set is what keeps the client's
cache replacement from dropping the terminals that live at the root.
2026-05-31 12:29:05 +08:00

238 lines
7.8 KiB
TypeScript

import { describe, expect, test, vi } from "vitest";
import type pino from "pino";
import type { SessionOutboundMessage } from "../server/messages.js";
import {
TerminalStreamOpcode,
decodeTerminalStreamFrame,
type TerminalStreamFrame,
} from "@getpaseo/protocol/binary-frames/index";
import type { TerminalCell, TerminalState } from "@getpaseo/protocol/messages";
import type { ServerMessage, TerminalSession, TerminalStateSnapshot } from "./terminal.js";
import { TerminalSessionController } from "./terminal-session-controller.js";
import type { TerminalManager, TerminalsChangedEvent } from "./terminal-manager.js";
import { isSameOrDescendantPath } from "../server/path-utils.js";
function deferred<T>(): {
promise: Promise<T>;
resolve: (value: T) => void;
reject: (error: unknown) => void;
} {
let resolve!: (value: T) => void;
let reject!: (error: unknown) => void;
const promise = new Promise<T>((promiseResolve, promiseReject) => {
resolve = promiseResolve;
reject = promiseReject;
});
return { promise, resolve, reject };
}
function terminalRow(text: string, cols = 80): TerminalCell[] {
return Array.from({ length: cols }, (_, index) => ({
char: text[index] ?? " ",
}));
}
function terminalState(text: string): TerminalState {
return {
rows: 1,
cols: 80,
grid: [terminalRow(text)],
scrollback: [],
cursor: { row: 0, col: text.length },
};
}
function createLogger(): pino.Logger {
return {
warn: vi.fn(),
error: vi.fn(),
} as unknown as pino.Logger;
}
describe("terminal-session-controller restore", () => {
test("delivers output produced while restore is in flight after the restore frame", async () => {
let terminalListener: ((message: ServerMessage) => void) | null = null;
const snapshot = deferred<TerminalStateSnapshot | null>();
const binaryFrames: TerminalStreamFrame[] = [];
const outboundMessages: SessionOutboundMessage[] = [];
const terminal: TerminalSession = {
id: "term-1",
name: "Terminal",
cwd: "/tmp",
send: vi.fn(),
subscribe: (listener) => {
terminalListener = listener;
queueMicrotask(() => listener({ type: "snapshotReady", revision: 1 }));
return vi.fn();
},
onExit: () => vi.fn(),
onCommandFinished: () => vi.fn(),
onTitleChange: () => vi.fn(),
getSize: () => ({ rows: 1, cols: 80 }),
getState: () => terminalState("restore-before"),
getStateSnapshot: () => ({ state: terminalState("restore-before"), revision: 1 }),
getReplayPreamble: () => "",
getTitle: () => undefined,
setTitle: vi.fn(),
getExitInfo: () => null,
kill: vi.fn(),
killAndWait: vi.fn(),
};
const terminalManager: TerminalManager = {
getTerminals: vi.fn(),
createTerminal: vi.fn(),
registerCwdEnv: vi.fn(),
getTerminal: vi.fn(() => terminal),
getTerminalState: vi.fn(() => snapshot.promise),
setTerminalTitle: vi.fn(),
killTerminal: vi.fn(),
killTerminalAndWait: vi.fn(),
captureTerminal: vi.fn(),
listDirectories: vi.fn(() => []),
killAll: vi.fn(),
subscribeTerminalsChanged: vi.fn(() => vi.fn()),
};
const controller = new TerminalSessionController({
terminalManager,
emit: (message) => outboundMessages.push(message),
emitBinary: (bytes) => {
const frame = decodeTerminalStreamFrame(bytes);
if (frame) {
binaryFrames.push(frame);
}
},
hasBinaryChannel: () => true,
isPathWithinRoot: () => false,
sessionLogger: createLogger(),
});
await controller.dispatch({
type: "subscribe_terminal_request",
terminalId: "term-1",
requestId: "req-1",
restore: {
mode: "visible-snapshot",
scrollbackLines: 200,
},
});
await Promise.resolve();
expect(terminalManager.getTerminalState).toHaveBeenCalledTimes(1);
terminalListener?.({ type: "output", data: "restore-after\n", revision: 2 });
snapshot.resolve({ state: terminalState("restore-before"), revision: 1 });
await snapshot.promise;
await new Promise((resolve) => setTimeout(resolve, 20));
expect(outboundMessages).toContainEqual({
type: "subscribe_terminal_response",
payload: {
terminalId: "term-1",
slot: 0,
error: null,
requestId: "req-1",
},
});
expect(binaryFrames.map((frame) => frame.opcode)).toEqual([
TerminalStreamOpcode.Restore,
TerminalStreamOpcode.Output,
]);
expect(new TextDecoder().decode(binaryFrames[0]?.payload)).toContain("restore-before");
expect(new TextDecoder().decode(binaryFrames[1]?.payload)).toBe("restore-after\n");
});
});
function listSession(input: { id: string; name: string; cwd: string }): TerminalSession {
return {
id: input.id,
name: input.name,
cwd: input.cwd,
send: vi.fn(),
subscribe: () => vi.fn(),
onExit: () => vi.fn(),
onCommandFinished: () => vi.fn(),
onTitleChange: () => vi.fn(),
getSize: () => ({ rows: 1, cols: 80 }),
getState: () => terminalState(""),
getStateSnapshot: () => ({ state: terminalState(""), revision: 0 }),
getReplayPreamble: () => "",
getTitle: () => undefined,
setTitle: vi.fn(),
getExitInfo: () => null,
kill: vi.fn(),
killAndWait: vi.fn(),
};
}
async function flushMicrotasks(): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, 0));
}
describe("terminal-session-controller subdirectory aggregation", () => {
test("delivers a subdirectory change to a root subscriber as an aggregated, root-keyed snapshot", async () => {
const rootCwd = "/work/repo";
const subdirCwd = "/work/repo/apps/mobile";
// Aggregating subdirectory buckets into the root query is the manager's
// contract, covered by terminal-manager.test.ts. Here we only assert the
// controller re-fetches by root and keys the snapshot by root, so the fake
// returns a fixed aggregated list for the root and nothing otherwise.
const aggregatedRootTerminals = [
listSession({ id: "root-term", name: "Terminal 1", cwd: rootCwd }),
listSession({ id: "subdir-term", name: "Mobile", cwd: subdirCwd }),
];
let changedListener: ((event: TerminalsChangedEvent) => void) | null = null;
const terminalManager: TerminalManager = {
getTerminals: vi.fn(async (cwd: string) => (cwd === rootCwd ? aggregatedRootTerminals : [])),
createTerminal: vi.fn(),
registerCwdEnv: vi.fn(),
getTerminal: vi.fn(),
getTerminalState: vi.fn(),
setTerminalTitle: vi.fn(),
killTerminal: vi.fn(),
killTerminalAndWait: vi.fn(),
captureTerminal: vi.fn(),
listDirectories: vi.fn(() => [rootCwd, subdirCwd]),
killAll: vi.fn(),
subscribeTerminalsChanged: vi.fn((listener) => {
changedListener = listener;
return vi.fn();
}),
};
const outboundMessages: SessionOutboundMessage[] = [];
const controller = new TerminalSessionController({
terminalManager,
emit: (message) => outboundMessages.push(message),
emitBinary: vi.fn(),
hasBinaryChannel: () => true,
isPathWithinRoot: isSameOrDescendantPath,
sessionLogger: createLogger(),
});
controller.start();
controller.dispatch({ type: "subscribe_terminals_request", cwd: rootCwd });
await flushMicrotasks();
outboundMessages.length = 0;
changedListener?.({
cwd: subdirCwd,
terminals: [{ id: "subdir-term", name: "Mobile", cwd: subdirCwd }],
});
await flushMicrotasks();
expect(outboundMessages).toEqual([
{
type: "terminals_changed",
payload: {
cwd: rootCwd,
terminals: [
{ id: "root-term", name: "Terminal 1" },
{ id: "subdir-term", name: "Mobile" },
],
},
},
]);
});
});