mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-31 13:42:50 +00:00
feat(terminal): stream negotiated ANSI replay to clients
Restore configured client history up to host retention without collecting capable replay in the daemon. Preserve single-frame ANSI delivery for older clients and normalize older hosts at the SDK boundary.
This commit is contained in:
@@ -125,6 +125,7 @@ export class ConnectionSubscriptions extends OwnedSubscriptions {
|
||||
// Protocol support belongs to the installed client. Only browser hosting needs
|
||||
// a resource supplied by the caller. Keep this exhaustive as the protocol evolves.
|
||||
export const DEFAULT_CLIENT_CAPABILITIES = {
|
||||
[CLIENT_CAPS.terminalChunkedReplay]: true,
|
||||
[CLIENT_CAPS.helloRejection]: true,
|
||||
[CLIENT_CAPS.ownedSubscriptions]: true,
|
||||
[CLIENT_CAPS.allProviders]: true,
|
||||
|
||||
@@ -1101,6 +1101,7 @@ test("advertises client capabilities in hello", async () => {
|
||||
projected_subagent_timeline: true,
|
||||
reasoning_merge_enum: true,
|
||||
terminal_reflowable_snapshot: true,
|
||||
terminal_chunked_replay: true,
|
||||
timeline_notifications: true,
|
||||
plugin_timeline_items: true,
|
||||
workspace_setup_blocked: true,
|
||||
@@ -6387,66 +6388,80 @@ test("emits output events for the active terminal stream", async () => {
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
test("emits snapshot events for the subscribed terminal stream", async () => {
|
||||
const logger = createMockLogger();
|
||||
const mock = createMockTransport();
|
||||
test.each([5, 500, 10000])(
|
||||
"preserves explicit %s history demand on an old ANSI host and normalizes restore",
|
||||
async (history) => {
|
||||
const logger = createMockLogger();
|
||||
const mock = createMockTransport();
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: "ws://test",
|
||||
clientId: "clsk_unit_test",
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
});
|
||||
clients.push(client);
|
||||
const client = new DaemonClient({
|
||||
url: "ws://test",
|
||||
clientId: "clsk_unit_test",
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
});
|
||||
clients.push(client);
|
||||
|
||||
const connectPromise = client.connect();
|
||||
mock.triggerOpen();
|
||||
await connectPromise;
|
||||
const connectPromise = client.connect();
|
||||
mock.triggerOpen();
|
||||
await connectPromise;
|
||||
|
||||
const snapshots: unknown[] = [];
|
||||
client.onTerminalStreamEvent((event) => {
|
||||
if (event.type !== "snapshot") {
|
||||
return;
|
||||
}
|
||||
snapshots.push(event.state);
|
||||
});
|
||||
const events: unknown[] = [];
|
||||
const replayEvents: unknown[] = [];
|
||||
client.onTerminalStreamEvent((event) => {
|
||||
if (event.type === "replay") replayEvents.push(event.replay);
|
||||
events.push(event);
|
||||
});
|
||||
|
||||
const subscribePromise = client.subscribeTerminal("term-1", { requestId: "sub-2" });
|
||||
await vi.waitFor(() =>
|
||||
expect(parseSentFrame(mock.sent.at(-1)).type).toBe("subscribe_terminal_request"),
|
||||
);
|
||||
mock.triggerMessage(
|
||||
wrapSessionMessage({
|
||||
type: "subscribe_terminal_response",
|
||||
payload: {
|
||||
subscriptionId: "server-" + String(parseSentFrame(mock.sent.at(-1)).requestId),
|
||||
terminalId: "term-1",
|
||||
const subscribePromise = client.subscribeTerminal("term-1", {
|
||||
requestId: "sub-2",
|
||||
restore: { mode: "visible-snapshot", scrollbackLines: history },
|
||||
});
|
||||
await vi.waitFor(() =>
|
||||
expect(parseSentFrame(mock.sent.at(-1)).type).toBe("subscribe_terminal_request"),
|
||||
);
|
||||
expect(parseSentFrame(mock.sent.at(-1)).restore).toEqual({
|
||||
mode: "visible-snapshot",
|
||||
scrollbackLines: history,
|
||||
});
|
||||
mock.triggerMessage(
|
||||
wrapSessionMessage({
|
||||
type: "subscribe_terminal_response",
|
||||
payload: {
|
||||
subscriptionId: "server-" + String(parseSentFrame(mock.sent.at(-1)).requestId),
|
||||
terminalId: "term-1",
|
||||
slot: 12,
|
||||
error: null,
|
||||
requestId: "sub-2",
|
||||
},
|
||||
}),
|
||||
);
|
||||
await subscribePromise;
|
||||
|
||||
const state = {
|
||||
rows: 1,
|
||||
cols: 5,
|
||||
grid: [[{ char: "h" }, { char: "e" }, { char: "l" }, { char: "l" }, { char: "o" }]],
|
||||
scrollback: [],
|
||||
cursor: { row: 0, col: 5 },
|
||||
};
|
||||
mock.triggerMessage(
|
||||
encodeTerminalStreamFrame({
|
||||
opcode: TerminalStreamOpcode.Snapshot,
|
||||
slot: 12,
|
||||
error: null,
|
||||
requestId: "sub-2",
|
||||
},
|
||||
}),
|
||||
);
|
||||
await subscribePromise;
|
||||
payload: encodeTerminalSnapshotPayload(state),
|
||||
}),
|
||||
);
|
||||
|
||||
const state = {
|
||||
rows: 1,
|
||||
cols: 5,
|
||||
grid: [[{ char: "h" }, { char: "e" }, { char: "l" }, { char: "l" }, { char: "o" }]],
|
||||
scrollback: [],
|
||||
cursor: { row: 0, col: 5 },
|
||||
};
|
||||
mock.triggerMessage(
|
||||
encodeTerminalStreamFrame({
|
||||
opcode: TerminalStreamOpcode.Snapshot,
|
||||
slot: 12,
|
||||
payload: encodeTerminalSnapshotPayload(state),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(snapshots).toEqual([state]);
|
||||
});
|
||||
expect(events).toEqual([]);
|
||||
const data = new TextEncoder().encode("\x1b[31mold ANSI host界🙂");
|
||||
mock.triggerMessage(
|
||||
encodeTerminalStreamFrame({ opcode: TerminalStreamOpcode.Restore, slot: 12, payload: data }),
|
||||
);
|
||||
expect(replayEvents).toEqual([{ type: "begin" }, { type: "data", data }, { type: "end" }]);
|
||||
},
|
||||
);
|
||||
|
||||
test("sends explicit terminal input and resize commands", async () => {
|
||||
const logger = createMockLogger();
|
||||
|
||||
@@ -6414,7 +6414,7 @@ export class DaemonClient {
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot/restore and output arrive in receive. Subscribe to the returned handle
|
||||
* Replay begin/data/end and live output arrive in receive. Subscribe to the returned handle
|
||||
* for terminal_stream_exit: payload.error means observation failure, not PTY exit.
|
||||
* Either outcome detaches this slot and releases the handle automatically.
|
||||
*/
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export type { TerminalReplayEvent, TerminalStreamEvent } from "./terminal-stream-router.js";
|
||||
import type { AgentAddress } from "@getpaseo/protocol/addresses";
|
||||
import { parentAddressLabels } from "@getpaseo/protocol/agent-labels";
|
||||
export type { AgentAddress } from "@getpaseo/protocol/addresses";
|
||||
|
||||
@@ -4,7 +4,7 @@ import { describe, expect, test } from "vitest";
|
||||
import { TerminalStreamRouter, type TerminalStreamEvent } from "./terminal-stream-router.js";
|
||||
|
||||
describe("terminal-stream-router", () => {
|
||||
test("routes restore frames as restore events", () => {
|
||||
test("normalizes old ANSI restore into the replay lifecycle", () => {
|
||||
const router = new TerminalStreamRouter();
|
||||
const events: TerminalStreamEvent[] = [];
|
||||
const payload = new TextEncoder().encode("restored screen");
|
||||
@@ -16,14 +16,14 @@ describe("terminal-stream-router", () => {
|
||||
payload,
|
||||
});
|
||||
|
||||
expect(events).toEqual([
|
||||
{
|
||||
expect(events).toEqual(
|
||||
["begin", "data", "end"].map((type) => ({
|
||||
terminalId: "term-1",
|
||||
subscriptionId: "subscription-1",
|
||||
type: "restore",
|
||||
data: payload,
|
||||
},
|
||||
]);
|
||||
type: "replay",
|
||||
replay: type === "data" ? { type, data: payload } : { type },
|
||||
})),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -61,3 +61,45 @@ test("legacy listeners share a terminal slot without releasing each other", () =
|
||||
router.handleFrame(frame);
|
||||
expect(received).toEqual(["first", "second", "second"]);
|
||||
});
|
||||
|
||||
test("streams chunks without joining and ignores released slots during replay", () => {
|
||||
const router = new TerminalStreamRouter();
|
||||
const events: TerminalStreamEvent[] = [];
|
||||
const release = router.attach("old", "terminal", 4, (event) => events.push(event));
|
||||
const payload = new TextEncoder().encode(
|
||||
JSON.stringify({ rows: 24, cols: 80, retainedHistoryLines: 1000 }),
|
||||
);
|
||||
router.handleFrame({ opcode: TerminalStreamOpcode.ReplayBegin, slot: 4, payload });
|
||||
const data = new TextEncoder().encode("\x1b[31m界🙂");
|
||||
router.handleFrame({
|
||||
opcode: TerminalStreamOpcode.ReplayData,
|
||||
slot: 4,
|
||||
payload: data.subarray(0, 7),
|
||||
});
|
||||
release();
|
||||
router.attach("new", "terminal", 5, (event) => events.push(event));
|
||||
router.handleFrame({
|
||||
opcode: TerminalStreamOpcode.ReplayData,
|
||||
slot: 4,
|
||||
payload: data.subarray(7),
|
||||
});
|
||||
router.handleFrame({
|
||||
opcode: TerminalStreamOpcode.ReplayEnd,
|
||||
slot: 4,
|
||||
payload: new Uint8Array(),
|
||||
});
|
||||
expect(events).toHaveLength(2);
|
||||
expect(events[1]).toMatchObject({
|
||||
subscriptionId: "old",
|
||||
type: "replay",
|
||||
replay: { type: "data", data: data.subarray(0, 7) },
|
||||
});
|
||||
router.handleFrame({ opcode: TerminalStreamOpcode.ReplayBegin, slot: 5, payload });
|
||||
router.handleFrame({ opcode: TerminalStreamOpcode.ReplayData, slot: 5, payload: data });
|
||||
router.handleFrame({
|
||||
opcode: TerminalStreamOpcode.ReplayEnd,
|
||||
slot: 5,
|
||||
payload: new Uint8Array(),
|
||||
});
|
||||
expect(events.slice(2).map((event) => event.subscriptionId)).toEqual(["new", "new", "new"]);
|
||||
});
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import {
|
||||
decodeTerminalSnapshotPayload,
|
||||
TerminalStreamOpcode,
|
||||
TerminalReplayMetadataSchema,
|
||||
type TerminalStreamFrame,
|
||||
} from "@getpaseo/protocol/binary-frames/index";
|
||||
import type { TerminalState } from "@getpaseo/protocol/messages";
|
||||
|
||||
export type TerminalReplayEvent =
|
||||
| { type: "begin"; rows?: number; cols?: number; retainedHistoryLines?: number }
|
||||
| { type: "data"; data: Uint8Array }
|
||||
| { type: "end" };
|
||||
|
||||
export type TerminalStreamEvent = { terminalId: string; subscriptionId: string } & (
|
||||
| { type: "output"; data: Uint8Array }
|
||||
| { type: "snapshot"; state: TerminalState }
|
||||
| { type: "restore"; data: Uint8Array }
|
||||
| { type: "replay"; replay: TerminalReplayEvent }
|
||||
);
|
||||
|
||||
export class TerminalStreamRouter {
|
||||
@@ -61,20 +64,42 @@ export class TerminalStreamRouter {
|
||||
let event: TerminalStreamEvent;
|
||||
if (frame.opcode === TerminalStreamOpcode.Output)
|
||||
event = { ...identity, type: "output", data: frame.payload };
|
||||
else if (frame.opcode === TerminalStreamOpcode.Restore)
|
||||
event = { ...identity, type: "restore", data: frame.payload };
|
||||
else if (frame.opcode === TerminalStreamOpcode.Snapshot) {
|
||||
const state = decodeTerminalSnapshotPayload(frame.payload);
|
||||
if (!state) return;
|
||||
event = { ...identity, type: "snapshot", state };
|
||||
} else return;
|
||||
registration.receive(event);
|
||||
for (const listener of this.listeners) {
|
||||
else if (frame.opcode === TerminalStreamOpcode.Restore) {
|
||||
// COMPAT(terminalSingleRestore): added after v0.14.0; remove when ANSI host floor supports chunked replay after 2027-04-30.
|
||||
for (const replay of [
|
||||
{ type: "begin" },
|
||||
{ type: "data", data: frame.payload },
|
||||
{ type: "end" },
|
||||
] as TerminalReplayEvent[])
|
||||
this.deliver(registration.receive, { ...identity, type: "replay", replay });
|
||||
continue;
|
||||
} else if (frame.opcode === TerminalStreamOpcode.ReplayBegin) {
|
||||
let raw: unknown;
|
||||
try {
|
||||
listener(event);
|
||||
raw = JSON.parse(new TextDecoder().decode(frame.payload));
|
||||
} catch {
|
||||
/* Passive diagnostics cannot interrupt delivery. */
|
||||
return;
|
||||
}
|
||||
const parsed = TerminalReplayMetadataSchema.safeParse(raw);
|
||||
if (!parsed.success) return;
|
||||
const metadata = parsed.data;
|
||||
event = { ...identity, type: "replay", replay: { type: "begin", ...metadata } };
|
||||
} else if (frame.opcode === TerminalStreamOpcode.ReplayData)
|
||||
event = { ...identity, type: "replay", replay: { type: "data", data: frame.payload } };
|
||||
else if (frame.opcode === TerminalStreamOpcode.ReplayEnd)
|
||||
event = { ...identity, type: "replay", replay: { type: "end" } };
|
||||
else return;
|
||||
this.deliver(registration.receive, event);
|
||||
}
|
||||
}
|
||||
|
||||
private deliver(receive: (event: TerminalStreamEvent) => void, event: TerminalStreamEvent): void {
|
||||
receive(event);
|
||||
for (const listener of this.listeners) {
|
||||
try {
|
||||
listener(event);
|
||||
} catch {
|
||||
/* Passive diagnostics cannot interrupt delivery. */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user