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:
@@ -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 });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user