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:
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user