Files
next-tjwater-agent/src/chat/response-inputs.ts
T
jiang 6a49d2209e feat(agent): unify MCP tools and adopt Responses delivery
Move project and host queries into native MCP with host-owned OAuth/context,
while retaining memory, skills, actions and publication plugins. Add nearby-model
queries and explicit chat/task/learning model defaults.

Adopt Responses HTTP/SSE and native steer/queue inbox delivery, preserving scoped
session ownership, pending input edits and investigation communication.

Disable local-provider discovery: Server /health allowed native vLLM to probe
/v1/models on port 8000. A native before/after regression prevents recurrence.

Validation: 170 focused Bun tests; service/plugin typechecks; native discovery,
provider-discovery and input-inbox smoke checks; no paid model inference.
2026-10-19 18:46:58 +08:00

70 lines
3.1 KiB
TypeScript

export type InputDelivery = "steer" | "queue";
export type ResponseInput = { id: string; text: string; delivery: InputDelivery; status: "pending" | "delivered" };
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.size > 0; }
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) return;
if (type === "session.inbox.delivered") input.status = "delivered";
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;
}
}