65 lines
4.9 KiB
TypeScript
65 lines
4.9 KiB
TypeScript
import type { Express } from "express";
|
|
import { z } from "zod";
|
|
import { isTrustedInternalBridgeRequest } from "../product/internal-bridge-auth.js";
|
|
import { getRuntimeSessionContext } from "../runtime/sessionContext.js";
|
|
import { interactionQuestionsSchema } from "../chat/interaction-request-contract.js";
|
|
import type { InteractionRequestStore } from "../sessions/interaction-request-store.js";
|
|
import type { OpencodeRuntimeAdapter } from "../runtime/opencode.js";
|
|
|
|
const identity = { session_id: z.string().min(1).max(128) };
|
|
const call = z.object({ ...identity, call_id: z.string().min(1).max(128), tool: z.string().min(1).max(200) }).strict();
|
|
|
|
/** Only trusted executors enter this gate; model input never selects an owner or turn. */
|
|
export function registerChatInteractionBridge(app: Express, options: { internalToken: string; interactions: InteractionRequestStore; runtime?: OpencodeRuntimeAdapter }) {
|
|
const calls = new Map<string, { turn: string; active: Map<string, string> }>();
|
|
for (const operation of ["policy", "enter", "leave", "create"] as const) {
|
|
app.post(`/internal/chat-interactions/${operation}`, async (req, res) => {
|
|
if (!isTrustedInternalBridgeRequest(req, options.internalToken)) { res.status(403).json({ message: "forbidden" }); return; }
|
|
try {
|
|
const body = operation === "policy" ? z.object(identity).strict().parse(req.body)
|
|
: operation === "create" ? call.extend({ questions: interactionQuestionsSchema }).parse(req.body) : call.parse(req.body);
|
|
const context = getRuntimeSessionContext(body.session_id);
|
|
const enabled = Boolean(context?.hostTurnId && context.hostMessageId && context.capabilities?.includes("host-question@1")
|
|
&& context.purpose !== "task_execution" && context.learningMode !== "review");
|
|
if (operation === "policy") {
|
|
const requested = context && enabled ? await options.interactions.getForTurn(context, context.hostTurnId!) : null;
|
|
res.json({ enabled, waiting: Boolean(requested) }); return;
|
|
}
|
|
const input = call.parse({ session_id: body.session_id, call_id: "call_id" in body ? body.call_id : "", tool: "tool" in body ? body.tool : "" });
|
|
if (!context) { res.status(403).json({ message: "No authorized host turn is bound to this session" }); return; }
|
|
if (!enabled || !context) {
|
|
if (input.tool === "ask_user") { res.status(403).json({ message: "Host questions are not enabled for this turn" }); return; }
|
|
res.json({ allowed: true }); return;
|
|
}
|
|
let state = calls.get(context.sessionId);
|
|
if (!state || state.turn !== context.hostTurnId) { state = { turn: context.hostTurnId!, active: new Map() }; calls.set(context.sessionId, state); }
|
|
if (operation === "leave") { state.active.delete(input.call_id); res.json({ allowed: true }); return; }
|
|
if (context.hostQuestionClosed) { res.status(409).json({ message: "The owning turn has been stopped" }); return; }
|
|
if (operation === "create") {
|
|
if (input.tool !== "ask_user" || state.active.get(input.call_id) !== "ask_user" || !("questions" in body)) {
|
|
res.status(409).json({ message: "Question tool has not acquired the turn" }); return;
|
|
}
|
|
const previous = await options.interactions.getBlocking(context);
|
|
if (previous?.delivery && options.runtime && await options.runtime.agentMessageState(context.sessionId, previous.delivery.native_message_id) === "delivered") {
|
|
await options.interactions.updateDelivery(context, previous.interaction_id, { expected_revision: previous.delivery.revision, status: "delivered", response_id: previous.delivery.response_id });
|
|
}
|
|
const record = await options.interactions.create(context, { message_id: context.hostMessageId!, turn_id: context.hostTurnId!, call_id: input.call_id, questions: body.questions });
|
|
res.json(record); return;
|
|
}
|
|
const blocking = await options.interactions.getBlocking(context);
|
|
const requested = await options.interactions.getForTurn(context, context.hostTurnId!);
|
|
if (requested || blocking?.status === "pending" || input.tool === "question" || [...state.active.values()].includes("ask_user")
|
|
|| (input.tool === "ask_user" && state.active.size > 0)) {
|
|
res.status(409).json({ message: "Finish other tools before asking; after asking, wait for the user" }); return;
|
|
}
|
|
state.active.set(input.call_id, input.tool);
|
|
res.json({ allowed: true });
|
|
} catch (error) {
|
|
if (error instanceof z.ZodError) { res.status(400).json({ message: "invalid interaction bridge request" }); return; }
|
|
const status = error && typeof error === "object" && "status" in error && typeof error.status === "number" ? error.status : 503;
|
|
res.status(status).json({ message: "Interaction state is unavailable; no question was acknowledged" });
|
|
}
|
|
});
|
|
}
|
|
}
|