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.
70 lines
3.1 KiB
TypeScript
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;
|
|
}
|
|
}
|