refactor: remove draft agent routes and consolidate terminal output into session model
This commit is contained in:
@@ -74,6 +74,14 @@ export class TerminalOutputDeliveryQueue {
|
||||
|
||||
consume(input: { sequence: number }): void {
|
||||
if (this.inFlightChunk?.sequence !== input.sequence) {
|
||||
terminalDebugLog({
|
||||
scope: "output-delivery-queue",
|
||||
event: "consume:ignored",
|
||||
details: {
|
||||
sequence: input.sequence,
|
||||
inFlightSequence: this.inFlightChunk?.sequence ?? null,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
getTerminalOutputSession,
|
||||
releaseTerminalOutputSession,
|
||||
retainTerminalOutputSession,
|
||||
} from "./terminal-output-session";
|
||||
|
||||
describe("terminal-output-session", () => {
|
||||
async function flushAsyncWork(): Promise<void> {
|
||||
await Promise.resolve();
|
||||
await new Promise<void>((resolve) => {
|
||||
setTimeout(() => resolve(), 0);
|
||||
});
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
it("returns the same session instance for the same scope key", () => {
|
||||
const a = getTerminalOutputSession({
|
||||
scopeKey: "scope-a",
|
||||
maxOutputChars: 1_000,
|
||||
});
|
||||
const b = getTerminalOutputSession({
|
||||
scopeKey: "scope-a",
|
||||
maxOutputChars: 50,
|
||||
});
|
||||
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
|
||||
it("evicts a scope session when the retain count returns to zero", () => {
|
||||
const scopeKey = "scope-retain-release";
|
||||
const first = getTerminalOutputSession({
|
||||
scopeKey,
|
||||
maxOutputChars: 1_000,
|
||||
});
|
||||
|
||||
retainTerminalOutputSession({ scopeKey });
|
||||
releaseTerminalOutputSession({ scopeKey });
|
||||
|
||||
const second = getTerminalOutputSession({
|
||||
scopeKey,
|
||||
maxOutputChars: 1_000,
|
||||
});
|
||||
|
||||
expect(second).not.toBe(first);
|
||||
});
|
||||
|
||||
it("publishes selected terminal snapshot and chunk state via delivery/consume", async () => {
|
||||
const session = getTerminalOutputSession({
|
||||
scopeKey: "scope-session-state",
|
||||
maxOutputChars: 1_000,
|
||||
});
|
||||
|
||||
session.setSelectedTerminal({ terminalId: null });
|
||||
session.setSelectedTerminal({ terminalId: "term-1" });
|
||||
|
||||
session.append({
|
||||
terminalId: "term-1",
|
||||
text: "abc",
|
||||
replay: false,
|
||||
});
|
||||
await flushAsyncWork();
|
||||
|
||||
const stateAfterAppend = session.getState();
|
||||
expect(stateAfterAppend.selectedTerminalId).toBe("term-1");
|
||||
expect(stateAfterAppend.chunkSequence).toBeGreaterThan(0);
|
||||
expect(stateAfterAppend.chunkText).toBe("abc");
|
||||
expect(stateAfterAppend.snapshotText).toBe("abc");
|
||||
expect(stateAfterAppend.snapshotSequence).toBe(stateAfterAppend.chunkSequence);
|
||||
|
||||
session.consume({ sequence: stateAfterAppend.chunkSequence });
|
||||
|
||||
const stateAfterConsume = session.getState();
|
||||
expect(stateAfterConsume.snapshotText).toBe("abc");
|
||||
});
|
||||
|
||||
it("keeps chunks for non-selected terminals out of selected state", async () => {
|
||||
const session = getTerminalOutputSession({
|
||||
scopeKey: "scope-other-terminal",
|
||||
maxOutputChars: 1_000,
|
||||
});
|
||||
|
||||
session.setSelectedTerminal({ terminalId: "term-1" });
|
||||
session.append({
|
||||
terminalId: "term-2",
|
||||
text: "hidden",
|
||||
replay: false,
|
||||
});
|
||||
await flushAsyncWork();
|
||||
|
||||
const state = session.getState();
|
||||
expect(state.selectedTerminalId).toBe("term-1");
|
||||
expect(state.chunkText).toBe("");
|
||||
expect(state.snapshotText).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,215 @@
|
||||
import {
|
||||
TerminalOutputPump,
|
||||
type TerminalOutputChunk,
|
||||
} from "./terminal-output-pump";
|
||||
import {
|
||||
TerminalOutputDeliveryQueue,
|
||||
type TerminalOutputDeliveryChunk,
|
||||
} from "./terminal-output-delivery-queue";
|
||||
import { summarizeTerminalText, terminalDebugLog } from "./terminal-debug";
|
||||
|
||||
type TerminalOutputState = {
|
||||
selectedTerminalId: string | null;
|
||||
snapshotText: string;
|
||||
snapshotSequence: number;
|
||||
chunkText: string;
|
||||
chunkSequence: number;
|
||||
chunkReplay: boolean;
|
||||
};
|
||||
|
||||
const EMPTY_STATE: TerminalOutputState = {
|
||||
selectedTerminalId: null,
|
||||
snapshotText: "",
|
||||
snapshotSequence: 0,
|
||||
chunkText: "",
|
||||
chunkSequence: 0,
|
||||
chunkReplay: false,
|
||||
};
|
||||
|
||||
export type TerminalOutputSessionAppendInput = {
|
||||
terminalId: string;
|
||||
text: string;
|
||||
replay: boolean;
|
||||
};
|
||||
|
||||
export type TerminalOutputSessionSetSelectedInput = {
|
||||
terminalId: string | null;
|
||||
};
|
||||
|
||||
export type TerminalOutputSessionPruneInput = {
|
||||
terminalIds: string[];
|
||||
};
|
||||
|
||||
export type TerminalOutputSessionConsumeInput = {
|
||||
sequence: number;
|
||||
};
|
||||
|
||||
export class TerminalOutputSession {
|
||||
private readonly listeners = new Set<() => void>();
|
||||
private readonly outputPump: TerminalOutputPump;
|
||||
private readonly deliveryQueue: TerminalOutputDeliveryQueue;
|
||||
private state: TerminalOutputState = EMPTY_STATE;
|
||||
|
||||
constructor(input: { maxOutputChars: number }) {
|
||||
this.outputPump = new TerminalOutputPump({
|
||||
maxOutputChars: input.maxOutputChars,
|
||||
onSelectedOutputChunk: (chunk: TerminalOutputChunk) => {
|
||||
this.deliveryQueue.enqueue(chunk);
|
||||
},
|
||||
});
|
||||
|
||||
this.deliveryQueue = new TerminalOutputDeliveryQueue({
|
||||
onDeliver: (chunk: TerminalOutputDeliveryChunk) => {
|
||||
const snapshotText = this.outputPump.readSnapshot({
|
||||
terminalId: this.state.selectedTerminalId,
|
||||
});
|
||||
this.state = {
|
||||
...this.state,
|
||||
snapshotText,
|
||||
snapshotSequence: chunk.sequence,
|
||||
chunkText: chunk.text,
|
||||
chunkSequence: chunk.sequence,
|
||||
chunkReplay: chunk.replay,
|
||||
};
|
||||
terminalDebugLog({
|
||||
scope: "output-session",
|
||||
event: "deliver",
|
||||
details: {
|
||||
selectedTerminalId: this.state.selectedTerminalId,
|
||||
sequence: chunk.sequence,
|
||||
replay: chunk.replay,
|
||||
chunkLength: chunk.text.length,
|
||||
snapshotLength: snapshotText.length,
|
||||
preview: summarizeTerminalText({ text: chunk.text, maxChars: 80 }),
|
||||
},
|
||||
});
|
||||
this.emit();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
subscribe(listener: () => void): () => void {
|
||||
this.listeners.add(listener);
|
||||
return () => {
|
||||
this.listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
getState(): TerminalOutputState {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
setSelectedTerminal(input: TerminalOutputSessionSetSelectedInput): void {
|
||||
if (this.state.selectedTerminalId === input.terminalId) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.deliveryQueue.reset();
|
||||
this.outputPump.setSelectedTerminal({ terminalId: input.terminalId });
|
||||
const snapshotText = this.outputPump.readSnapshot({
|
||||
terminalId: input.terminalId,
|
||||
});
|
||||
this.state = {
|
||||
selectedTerminalId: input.terminalId,
|
||||
snapshotText,
|
||||
snapshotSequence: this.state.snapshotSequence,
|
||||
chunkText: "",
|
||||
chunkSequence: 0,
|
||||
chunkReplay: false,
|
||||
};
|
||||
terminalDebugLog({
|
||||
scope: "output-session",
|
||||
event: "selected-terminal:set",
|
||||
details: {
|
||||
selectedTerminalId: input.terminalId,
|
||||
snapshotLength: snapshotText.length,
|
||||
},
|
||||
});
|
||||
this.emit();
|
||||
}
|
||||
|
||||
append(input: TerminalOutputSessionAppendInput): void {
|
||||
this.outputPump.append(input);
|
||||
}
|
||||
|
||||
clearTerminal(input: { terminalId: string }): void {
|
||||
this.outputPump.clearTerminal(input);
|
||||
if (this.state.selectedTerminalId !== input.terminalId) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.deliveryQueue.reset();
|
||||
this.state = {
|
||||
...this.state,
|
||||
snapshotText: "",
|
||||
chunkText: "",
|
||||
chunkSequence: 0,
|
||||
chunkReplay: false,
|
||||
};
|
||||
terminalDebugLog({
|
||||
scope: "output-session",
|
||||
event: "selected-terminal:clear",
|
||||
details: {
|
||||
terminalId: input.terminalId,
|
||||
},
|
||||
});
|
||||
this.emit();
|
||||
}
|
||||
|
||||
prune(input: TerminalOutputSessionPruneInput): void {
|
||||
this.outputPump.prune(input);
|
||||
}
|
||||
|
||||
consume(input: TerminalOutputSessionConsumeInput): void {
|
||||
this.deliveryQueue.consume(input);
|
||||
}
|
||||
|
||||
private emit(): void {
|
||||
for (const listener of this.listeners) {
|
||||
listener();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const sessionsByScopeKey = new Map<string, TerminalOutputSession>();
|
||||
const sessionRefCountByScopeKey = new Map<string, number>();
|
||||
|
||||
export function getTerminalOutputSession(input: {
|
||||
scopeKey: string;
|
||||
maxOutputChars: number;
|
||||
}): TerminalOutputSession {
|
||||
const existing = sessionsByScopeKey.get(input.scopeKey);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const session = new TerminalOutputSession({
|
||||
maxOutputChars: input.maxOutputChars,
|
||||
});
|
||||
sessionsByScopeKey.set(input.scopeKey, session);
|
||||
return session;
|
||||
}
|
||||
|
||||
export function retainTerminalOutputSession(input: { scopeKey: string }): void {
|
||||
const current = sessionRefCountByScopeKey.get(input.scopeKey) ?? 0;
|
||||
sessionRefCountByScopeKey.set(input.scopeKey, current + 1);
|
||||
}
|
||||
|
||||
export function releaseTerminalOutputSession(input: { scopeKey: string }): void {
|
||||
const current = sessionRefCountByScopeKey.get(input.scopeKey) ?? 0;
|
||||
if (current <= 1) {
|
||||
sessionRefCountByScopeKey.delete(input.scopeKey);
|
||||
sessionsByScopeKey.delete(input.scopeKey);
|
||||
terminalDebugLog({
|
||||
scope: "output-session",
|
||||
event: "scope:release",
|
||||
details: {
|
||||
scopeKey: input.scopeKey,
|
||||
released: true,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
sessionRefCountByScopeKey.set(input.scopeKey, current - 1);
|
||||
}
|
||||
Reference in New Issue
Block a user