refactor(server): extract terminal stream state manager from daemon client

Move terminal stream buffering, handler fanout, and ack offset state out of DaemonClient into a focused TerminalStreamManager.\n\nReasoning:\n- daemon-client.ts mixed RPC transport concerns with terminal stream state machine details.\n- terminal buffering/ack logic is cohesive and testable in isolation, so splitting it lowers logic density and makes future stream changes safer.\n\nWhat changed:\n- Added daemon-client-terminal-stream-manager.ts to own stream handlers, buffered chunks, and ack monotonicity.\n- Updated DaemonClient to delegate terminal stream state transitions (subscribe, receive chunk, clear stream/all, note ack).\n- Preserved public API by re-exporting TerminalStreamChunk from daemon-client.ts.\n- Hardened daemon-client tests to assert behavior (no delivery/ack after detach or stream_exit) rather than private internal maps.\n- Added dedicated manager unit tests for buffering, eviction, handler-failure behavior, and stale ack suppression.\n\nVerification:\n- npm run test --workspace=@getpaseo/server -- src/client/daemon-client.test.ts src/client/daemon-client-terminal-stream-manager.test.ts\n- npm run typecheck\n\nNext-agent notes:\n- Accomplishment: terminal stream state is now isolated; daemon-client.ts is slimmer and easier to extend.\n- Challenge: daemon-client.ts is still large (~3k lines) with other cohesive seams remaining (dictation flow and checkout RPC groups).
This commit is contained in:
Mohamed Boudra
2026-02-14 09:51:49 +07:00
parent c87c206900
commit d9e14186ce
4 changed files with 366 additions and 139 deletions
@@ -0,0 +1,117 @@
import { describe, expect, test, vi } from "vitest";
import {
TerminalStreamManager,
type TerminalStreamChunk,
} from "./daemon-client-terminal-stream-manager.js";
function createChunk(input: {
streamId: number;
offset: number;
data: string;
}): TerminalStreamChunk {
const bytes = new TextEncoder().encode(input.data);
return {
streamId: input.streamId,
offset: input.offset,
endOffset: input.offset + bytes.byteLength,
replay: false,
data: bytes,
};
}
describe("TerminalStreamManager", () => {
test("buffers chunks and flushes with ack when handler subscribes", () => {
const sendAck = vi.fn();
const manager = new TerminalStreamManager({ sendAck });
const seen: string[] = [];
manager.receiveChunk({
chunk: createChunk({ streamId: 7, offset: 4, data: "hello" }),
});
expect(sendAck).not.toHaveBeenCalled();
manager.subscribe({
streamId: 7,
handler: (chunk) => {
seen.push(new TextDecoder().decode(chunk.data));
},
});
expect(seen).toEqual(["hello"]);
expect(sendAck).toHaveBeenCalledTimes(1);
expect(sendAck).toHaveBeenCalledWith({ streamId: 7, offset: 9 });
});
test("does not ack when every handler throws", () => {
const sendAck = vi.fn();
const manager = new TerminalStreamManager({ sendAck });
manager.subscribe({
streamId: 11,
handler: () => {
throw new Error("boom");
},
});
manager.receiveChunk({
chunk: createChunk({ streamId: 11, offset: 0, data: "x" }),
});
expect(sendAck).not.toHaveBeenCalled();
});
test("evicts oldest buffered chunks when max buffered chunk count is exceeded", () => {
const sendAck = vi.fn();
const manager = new TerminalStreamManager({
sendAck,
maxBufferedChunks: 2,
maxBufferedBytes: 1024,
});
manager.receiveChunk({
chunk: createChunk({ streamId: 5, offset: 0, data: "A" }),
});
manager.receiveChunk({
chunk: createChunk({ streamId: 5, offset: 1, data: "B" }),
});
manager.receiveChunk({
chunk: createChunk({ streamId: 5, offset: 2, data: "C" }),
});
const seen: string[] = [];
manager.subscribe({
streamId: 5,
handler: (chunk) => {
seen.push(new TextDecoder().decode(chunk.data));
},
});
expect(seen).toEqual(["B", "C"]);
expect(sendAck).toHaveBeenNthCalledWith(1, { streamId: 5, offset: 2 });
expect(sendAck).toHaveBeenNthCalledWith(2, { streamId: 5, offset: 3 });
});
test("tracks explicit ack offsets and skips stale auto-acks", () => {
const sendAck = vi.fn();
const manager = new TerminalStreamManager({ sendAck });
manager.subscribe({
streamId: 13,
handler: () => {
// no-op
},
});
manager.noteAck({ streamId: 13, offset: 10 });
manager.receiveChunk({
chunk: createChunk({ streamId: 13, offset: 0, data: "abc" }),
});
expect(sendAck).not.toHaveBeenCalled();
manager.receiveChunk({
chunk: createChunk({ streamId: 13, offset: 11, data: "z" }),
});
expect(sendAck).toHaveBeenCalledTimes(1);
expect(sendAck).toHaveBeenCalledWith({ streamId: 13, offset: 12 });
});
});