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
@@ -707,7 +707,7 @@ describe("DaemonClient", () => {
expect(ackFrame?.offset).toBe(8);
});
test("cleans terminal stream local state on terminal_stream_exit", async () => {
test("stops delivering and acking after terminal_stream_exit", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
@@ -723,16 +723,22 @@ describe("DaemonClient", () => {
mock.triggerOpen();
await connectPromise;
const unsubscribe = client.onTerminalStreamData(23, () => {
// no-op
const seen: string[] = [];
const unsubscribe = client.onTerminalStreamData(23, (chunk) => {
seen.push(new TextDecoder().decode(chunk.data));
});
const internal = client as unknown as {
terminalStreamHandlers: Map<number, Set<(chunk: unknown) => void>>;
bufferedTerminalStreamChunks: Map<number, { chunks: unknown[]; bytes: number }>;
terminalStreamAckOffsets: Map<number, number>;
};
internal.bufferedTerminalStreamChunks.set(23, { chunks: [], bytes: 0 });
internal.terminalStreamAckOffsets.set(23, 100);
mock.triggerMessage(
encodeBinaryMuxFrame({
channel: BinaryMuxChannel.Terminal,
messageType: TerminalBinaryMessageType.OutputUtf8,
streamId: 23,
offset: 0,
payload: new TextEncoder().encode("before-exit"),
})
);
expect(seen).toEqual(["before-exit"]);
expect(mock.sent).toHaveLength(1);
mock.sent.length = 0;
mock.triggerMessage(
wrapSessionMessage({
@@ -744,9 +750,18 @@ describe("DaemonClient", () => {
})
);
expect(internal.terminalStreamHandlers.has(23)).toBe(false);
expect(internal.bufferedTerminalStreamChunks.has(23)).toBe(false);
expect(internal.terminalStreamAckOffsets.has(23)).toBe(false);
mock.triggerMessage(
encodeBinaryMuxFrame({
channel: BinaryMuxChannel.Terminal,
messageType: TerminalBinaryMessageType.OutputUtf8,
streamId: 23,
offset: 12,
payload: new TextEncoder().encode("after-exit"),
})
);
expect(seen).toEqual(["before-exit"]);
expect(mock.sent).toHaveLength(0);
unsubscribe();
});
@@ -766,15 +781,10 @@ describe("DaemonClient", () => {
mock.triggerOpen();
await connectPromise;
client.onTerminalStreamData(31, () => {
// no-op
const seen: string[] = [];
client.onTerminalStreamData(31, (chunk) => {
seen.push(new TextDecoder().decode(chunk.data));
});
const internal = client as unknown as {
bufferedTerminalStreamChunks: Map<number, { chunks: unknown[]; bytes: number }>;
terminalStreamAckOffsets: Map<number, number>;
};
internal.bufferedTerminalStreamChunks.set(31, { chunks: [], bytes: 0 });
internal.terminalStreamAckOffsets.set(31, 55);
const detachPromise = client.detachTerminalStream(31, "detach-31");
const detachRequest = JSON.parse(String(mock.sent[0])) as {
@@ -797,8 +807,20 @@ describe("DaemonClient", () => {
const payload = await detachPromise;
expect(payload.success).toBe(false);
expect(internal.bufferedTerminalStreamChunks.has(31)).toBe(false);
expect(internal.terminalStreamAckOffsets.has(31)).toBe(false);
mock.sent.length = 0;
mock.triggerMessage(
encodeBinaryMuxFrame({
channel: BinaryMuxChannel.Terminal,
messageType: TerminalBinaryMessageType.OutputUtf8,
streamId: 31,
offset: 0,
payload: new TextEncoder().encode("after-detach"),
})
);
expect(seen).toEqual([]);
expect(mock.sent).toHaveLength(0);
});
test("parses canonical agent_stream tool_call payloads without crashing", async () => {