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:
Mohamed Boudra
2026-10-30 09:56:54 +01:00
parent 9ba63a30fa
commit bfe2b10cef
44 changed files with 787 additions and 499 deletions
+1
View File
@@ -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,
+69 -54
View File
@@ -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();
+1 -1
View File
@@ -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
View File
@@ -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"]);
});
+40 -15
View File
@@ -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. */
}
}
}