100 lines
4.9 KiB
TypeScript
100 lines
4.9 KiB
TypeScript
export type InputDelivery = "steer" | "queue";
|
|
export type ResponseInput = { id: string; text: string; delivery: InputDelivery; status: "pending" | "delivered" | "cancelled" };
|
|
type WaitingInboxRuntime = {
|
|
listPendingUserInputs: (sessionId: string) => Promise<ResponseInput[]>;
|
|
agentMessageState: (sessionId: string, id: string) => Promise<"missing" | "enqueued" | "delivered">;
|
|
enqueueUserInput: (sessionId: string, input: ResponseInput, options?: { resume?: boolean }) => Promise<void>;
|
|
};
|
|
|
|
/** A question boundary admits input without starting an unowned model loop. */
|
|
export class WaitingResponseInputQueue {
|
|
private readonly operations = new Map<string, Promise<ResponseInput>>();
|
|
constructor(private readonly runtime: WaitingInboxRuntime) {}
|
|
|
|
async submit(sessionId: string, input: ResponseInput): Promise<ResponseInput> {
|
|
const operation = (this.operations.get(sessionId) ?? Promise.resolve()).catch(() => undefined).then(async () => {
|
|
const pending = await this.runtime.listPendingUserInputs(sessionId);
|
|
const previous = pending.find((item) => item.id === input.id);
|
|
if (previous) {
|
|
if (previous.text !== input.text) throw Object.assign(new Error("Input ID already belongs to another message"), { status: 409 });
|
|
return previous;
|
|
}
|
|
if (await this.runtime.agentMessageState(sessionId, input.id) === "delivered") return { ...input, status: "delivered" as const };
|
|
if (pending.length >= 20) throw Object.assign(new Error("At most 20 user messages may wait in this session"), { status: 429 });
|
|
await this.runtime.enqueueUserInput(sessionId, input, { resume: false });
|
|
return { ...input };
|
|
});
|
|
this.operations.set(sessionId, operation);
|
|
try { return await operation; }
|
|
finally { if (this.operations.get(sessionId) === operation) this.operations.delete(sessionId); }
|
|
}
|
|
}
|
|
type InboxRuntime = {
|
|
enqueueUserInput: (sessionId: string, input: ResponseInput) => Promise<void>;
|
|
listPendingUserInputs: (sessionId: string) => Promise<ResponseInput[]>;
|
|
};
|
|
const unavailable = () => Object.assign(new Error("The response is closing; send after it finishes"), { status: 409 });
|
|
|
|
/** Tracks native inbox receipts, never starts a second host generation loop. */
|
|
export class ResponseInputTracker {
|
|
private readonly items = new Map<string, ResponseInput>();
|
|
private readonly operations = new Set<Promise<unknown>>();
|
|
private accepting = true;
|
|
private releaseReady!: () => void;
|
|
private readonly ready = new Promise<void>((resolve) => { this.releaseReady = resolve; });
|
|
|
|
constructor(private readonly runtime: InboxRuntime, private readonly sessionId: string,
|
|
private readonly changed: (input: ResponseInput) => void) {}
|
|
|
|
markReady() { this.releaseReady(); }
|
|
close() { this.accepting = false; this.releaseReady(); }
|
|
get hasAdditionalInput() { return [...this.items.values()].some((input) => input.status !== "cancelled"); }
|
|
|
|
async restore() {
|
|
for (const input of await this.runtime.listPendingUserInputs(this.sessionId)) this.items.set(input.id, input);
|
|
}
|
|
|
|
async submit(input: ResponseInput): Promise<ResponseInput> {
|
|
if (!this.accepting) throw unavailable();
|
|
const previous = this.items.get(input.id);
|
|
if (previous) {
|
|
if (previous.text !== input.text) throw Object.assign(new Error("Input ID already belongs to another message"), { status: 409 });
|
|
await Promise.all([...this.operations]);
|
|
return { ...previous };
|
|
}
|
|
if ([...this.items.values()].filter((item) => item.status === "pending").length >= 20) {
|
|
throw Object.assign(new Error("At most 20 user messages may wait in this session"), { status: 429 });
|
|
}
|
|
if (this.items.size >= 500) throw Object.assign(new Error("Start a new turn before adding more input"), { status: 429 });
|
|
this.items.set(input.id, input);
|
|
const operation = (async () => {
|
|
await this.ready;
|
|
if (!this.accepting) throw unavailable();
|
|
await this.runtime.enqueueUserInput(this.sessionId, input);
|
|
this.changed({ ...input });
|
|
return { ...input };
|
|
})();
|
|
this.operations.add(operation);
|
|
try { return await operation; }
|
|
catch (error) { if (input.status === "pending") this.items.delete(input.id); throw error; }
|
|
finally { this.operations.delete(operation); }
|
|
}
|
|
|
|
observe(type: string, data: { inboxID?: string; delivery?: InputDelivery }) {
|
|
const input = data.inboxID ? this.items.get(data.inboxID) : undefined;
|
|
if (!input || input.status !== "pending") return;
|
|
if (type === "session.inbox.delivered") input.status = "delivered";
|
|
else if (type === "session.inbox.cancelled") input.status = "cancelled";
|
|
else if (type === "session.inbox.delivery.changed" && data.delivery) input.delivery = data.delivery;
|
|
else return;
|
|
this.changed({ ...input });
|
|
}
|
|
|
|
async canClose(): Promise<boolean> {
|
|
await Promise.allSettled([...this.operations]);
|
|
if ([...this.items.values()].some((input) => input.status === "pending")) return false;
|
|
this.accepting = false;
|
|
return true;
|
|
}
|
|
}
|