fix: preserve interaction replies across chat recovery
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
import type { UIMessage } from "ai";
|
||||
import { renderInteractionReplyText, type InteractionRequest } from "./interaction-request-contract.js";
|
||||
|
||||
/** Stable user presentation, including accepted-answer data for a cold reload. */
|
||||
export function createInteractionReplyMessage(record: InteractionRequest): UIMessage {
|
||||
if (!record.delivery) throw new Error("Submitted interaction has no delivery identity");
|
||||
return { id: record.delivery.native_message_id, role: "user", parts: [
|
||||
{ type: "text", text: renderInteractionReplyText(record) },
|
||||
{ type: "data-interaction_reply", data: structuredClone(record) },
|
||||
] };
|
||||
}
|
||||
|
||||
/** Only verified delivery IDs are projected; unrelated user text is never prefix-matched. */
|
||||
export function projectInteractionReplyMessages(messages: unknown[], records: InteractionRequest[]): unknown[] {
|
||||
const replies = new Map(records.filter((record) => record.delivery && ["answered", "skipped"].includes(record.status))
|
||||
.map((record) => [record.delivery!.native_message_id, record]));
|
||||
return messages.map((message) => {
|
||||
if (!message || typeof message !== "object") return message;
|
||||
const value = message as Record<string, unknown>;
|
||||
const record = value.role === "user" && typeof value.id === "string" ? replies.get(value.id) : undefined;
|
||||
if (!record) return message;
|
||||
const reply = createInteractionReplyMessage(record);
|
||||
const otherParts = Array.isArray(value.parts) ? value.parts.filter((part) => part && typeof part === "object"
|
||||
&& part.type !== "text" && part.type !== "data-interaction_reply") : [];
|
||||
return { ...value, ...reply, ...(typeof value.content === "string" ? { content: renderInteractionReplyText(record) } : {}),
|
||||
parts: [...reply.parts, ...otherParts] };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const INTERACTION_VERSION = "agent-interaction-request@1" as const;
|
||||
const id = z.string().min(1).max(128);
|
||||
const unique = (values: string[]) => new Set(values).size === values.length;
|
||||
export const interactionQuestionSchema = z.object({
|
||||
question_id: id, title: z.string().min(1).max(200), body: z.string().min(1).max(2000),
|
||||
options: z.array(z.object({ id, label: z.string().min(1).max(200) }).strict()).max(12),
|
||||
mode: z.enum(["single", "multiple"]), allow_custom: z.boolean(), required: z.boolean(),
|
||||
}).strict().refine((q) => unique(q.options.map((o) => o.id)), "duplicate option ids")
|
||||
.refine((q) => q.options.length > 0 || q.allow_custom, "question must offer an answer input");
|
||||
const byteLimit = (value: unknown, max: number) => Buffer.byteLength(JSON.stringify(value), "utf8") <= max;
|
||||
export const interactionQuestionsSchema = z.array(interactionQuestionSchema).min(1).max(3)
|
||||
.refine((qs) => unique(qs.map((q) => q.question_id)), "duplicate question ids")
|
||||
.refine((qs) => qs.reduce((n, q) => n + q.title.length + q.body.length + q.options.reduce((m, o) => m + o.label.length, 0), 0) <= 10000, "definition text too long")
|
||||
.refine((qs) => byteLimit(qs, 32768), "definition too large");
|
||||
export const interactionAnswerSchema = z.object({ question_id: id, selected_option_ids: z.array(id).max(12), custom_text: z.string().max(2000) }).strict()
|
||||
.refine((a) => unique(a.selected_option_ids), "duplicate selected option ids");
|
||||
export const interactionAnswersSchema = z.array(interactionAnswerSchema).max(3)
|
||||
.refine((as) => unique(as.map((a) => a.question_id)), "duplicate question ids")
|
||||
.refine((as) => as.reduce((n, a) => n + a.custom_text.length, 0) <= 10000, "answer text too long")
|
||||
.refine((as) => byteLimit(as, 16384), "answers too large");
|
||||
const write = { version: z.literal(INTERACTION_VERSION), request_id: id };
|
||||
export const interactionDraftInputSchema = z.object({ ...write, expected_draft_revision: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER), answers: interactionAnswersSchema }).strict();
|
||||
export const interactionReplyInputSchema = z.object({ ...write, expected_revision: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER), answers: interactionAnswersSchema }).strict();
|
||||
export const interactionMutationInputSchema = z.object({ ...write, expected_revision: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER) }).strict();
|
||||
export type InteractionQuestion = z.infer<typeof interactionQuestionSchema>;
|
||||
export type InteractionAnswer = z.infer<typeof interactionAnswerSchema>;
|
||||
export type InteractionResolvedAnswer = InteractionAnswer & { selected_options: Array<{ id: string; label: string }> };
|
||||
export type InteractionStatus = "pending" | "answered" | "skipped" | "cancelled" | "invalidated";
|
||||
export type InteractionDelivery = { native_message_id: string; revision: number; status: "queued" | "dispatching" | "delivered" | "blocked" | "cancelled"; error_code?: string; response_id?: string };
|
||||
export type InteractionRequest = {
|
||||
version: typeof INTERACTION_VERSION; interaction_id: string; message_id: string; turn_id: string; call_id: string;
|
||||
questions: InteractionQuestion[]; status: InteractionStatus; revision: number; draft_revision: number;
|
||||
draft: InteractionAnswer[]; answers?: InteractionResolvedAnswer[]; created_at: string; resolved_at?: string;
|
||||
delivery?: InteractionDelivery;
|
||||
};
|
||||
export const interactionDeliverySchema = z.object({
|
||||
native_message_id: z.string().regex(/^msg_[a-f0-9]{32}$/), revision: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER),
|
||||
status: z.enum(["queued", "dispatching", "delivered", "blocked", "cancelled"]), error_code: id.optional(), response_id: id.optional(),
|
||||
}).strict();
|
||||
export const interactionRequestSchema = z.object({
|
||||
version: z.literal(INTERACTION_VERSION), interaction_id: id, message_id: id, turn_id: id, call_id: id,
|
||||
questions: interactionQuestionsSchema, status: z.enum(["pending", "answered", "skipped", "cancelled", "invalidated"]),
|
||||
revision: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER), draft_revision: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER),
|
||||
draft: interactionAnswersSchema,
|
||||
answers: z.array(z.object({ question_id: id, selected_option_ids: z.array(id).max(12), custom_text: z.string().max(2000), selected_options: z.array(z.object({ id, label: z.string().min(1).max(200) }).strict()).max(12) }).strict()).max(3).optional(),
|
||||
created_at: z.string().datetime(), resolved_at: z.string().datetime().optional(), delivery: interactionDeliverySchema.optional(),
|
||||
}).strict();
|
||||
export const interactionBlocksTurn = (r: InteractionRequest) => r.status === "pending" || Boolean(r.delivery && !["delivered", "cancelled"].includes(r.delivery.status));
|
||||
|
||||
export const renderInteractionReplyText = (record: InteractionRequest): string => {
|
||||
if (record.status === "skipped") return "已跳过本组可选问题。";
|
||||
if (record.status !== "answered") throw new Error("interaction has no submitted answer");
|
||||
const lines = (record.answers ?? []).map((answer) => {
|
||||
const question = record.questions.find((q) => q.question_id === answer.question_id);
|
||||
return `${question?.title ?? answer.question_id}: ${[...answer.selected_options.map((o) => o.label), answer.custom_text].filter(Boolean).join(";") || "未填写"}`;
|
||||
});
|
||||
return lines.join("\n");
|
||||
};
|
||||
|
||||
/** Model-facing protocol stays separate from the persisted human reply. */
|
||||
export const renderInteractionAnswer = (record: InteractionRequest): string => {
|
||||
if (record.status === "skipped") return "用户明确跳过了本组非必填问题。";
|
||||
const text = `用户提交的提问回复:\n${renderInteractionReplyText(record)}`;
|
||||
if (text.length > 10000) throw new Error("rendered interaction answer exceeds 10000 characters");
|
||||
return text;
|
||||
};
|
||||
@@ -1,5 +1,34 @@
|
||||
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[]>;
|
||||
|
||||
@@ -22,6 +22,7 @@ export const responseRequestSchema = z.object({
|
||||
client_message_id: z.string().min(1).max(128).optional(),
|
||||
delivery: z.enum(["steer", "queue"]).optional(),
|
||||
input_id: z.string().regex(/^msg_[a-f0-9]{32}$/).optional(),
|
||||
interaction_id: z.string().min(1).max(128).optional(),
|
||||
approval_mode: z.enum(["request", "always"]).default("request"),
|
||||
capabilities: z.array(z.string().max(64)).max(20).default([]),
|
||||
trigger_context: agentTriggerContextSchema.optional(),
|
||||
|
||||
@@ -35,6 +35,7 @@ export class ChatSessionBridge {
|
||||
constructor(private readonly runtime: OpencodeRuntimeAdapter) {}
|
||||
|
||||
async resolve(context: {
|
||||
preserveExecution?: boolean;
|
||||
sessionId?: string;
|
||||
network?: string;
|
||||
projectId?: string;
|
||||
@@ -49,7 +50,7 @@ export class ChatSessionBridge {
|
||||
}> {
|
||||
let requestContext = this.buildRequestContext(context);
|
||||
const existingSessionId = context.sessionId?.trim();
|
||||
await this.abortActiveRuntime(requestContext.clientSessionId, existingSessionId);
|
||||
if (!context.preserveExecution) await this.abortActiveRuntime(requestContext.clientSessionId, existingSessionId);
|
||||
|
||||
let sessionId = existingSessionId;
|
||||
let created = false;
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
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" });
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { registerInteractionRequestRoutes } from "./interaction-request-routes.js";
|
||||
import type { InteractionRequestStore } from "../sessions/interaction-request-store.js";
|
||||
import { Router, type NextFunction, type Request, type Response } from "express";
|
||||
|
||||
import { agentModelOptions, resolveDefaultModel } from "../chat/models.js";
|
||||
@@ -31,6 +33,7 @@ import { registerArtifactRoutes } from "./artifact-routes.js";
|
||||
import type { AgentMessageCoordinator } from "../chat/agent-message-coordinator.js";
|
||||
|
||||
export type BuildChatRouterOptions = {
|
||||
interactionRequestStore?: InteractionRequestStore;
|
||||
agentMessages?: AgentMessageCoordinator;
|
||||
frontendActionCoordinator: FrontendActionCoordinator;
|
||||
learningOrchestrator: LearningOrchestrator;
|
||||
@@ -77,6 +80,8 @@ export const buildChatRouter = (options: BuildChatRouterOptions) => {
|
||||
models: agentModelOptions,
|
||||
});
|
||||
});
|
||||
if (options.interactionRequestStore) registerInteractionRequestRoutes(chatRouter, { sessions: sessionMetadataStore, interactions: options.interactionRequestStore,
|
||||
runtime, isRunning: (sessionId) => activeRuns.get(sessionId)?.status === "running" });
|
||||
registerArtifactRoutes(chatRouter, { sessionMetadataStore, runtime });
|
||||
|
||||
chatRouter.get("/ui-registry", (_req, res) => {
|
||||
@@ -131,6 +136,7 @@ export const buildChatRouter = (options: BuildChatRouterOptions) => {
|
||||
});
|
||||
|
||||
registerChatSessionRoutes(chatRouter, {
|
||||
interactionRequestStore: options.interactionRequestStore,
|
||||
learningOrchestrator,
|
||||
frontendActionCoordinator,
|
||||
runState,
|
||||
@@ -145,6 +151,8 @@ export const buildChatRouter = (options: BuildChatRouterOptions) => {
|
||||
if (agentMessages) registerAgentMessageRoutes(chatRouter, { sessions: sessionMetadataStore, messages: agentMessages });
|
||||
|
||||
registerChatAuxiliaryRoutes(chatRouter, {
|
||||
interactionRequestStore: options.interactionRequestStore,
|
||||
runtime,
|
||||
activeRuns,
|
||||
lastRunStatuses,
|
||||
sessionBridge,
|
||||
@@ -162,6 +170,7 @@ export const buildChatRouter = (options: BuildChatRouterOptions) => {
|
||||
|
||||
registerResponseInputRoutes(chatRouter, { runtime, sessionMetadataStore, runState });
|
||||
registerChatStreamRoute(chatRouter, {
|
||||
interactionRequestStore: options.interactionRequestStore,
|
||||
agentMessages,
|
||||
frontendActionCoordinator,
|
||||
learningOrchestrator,
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { type Router } from "express";
|
||||
import { z } from "zod";
|
||||
import type { InteractionRequestStore } from "../sessions/interaction-request-store.js";
|
||||
import type { OpencodeRuntimeAdapter } from "../runtime/opencode.js";
|
||||
import { getRuntimeSessionContext, setRuntimeSessionContext } from "../runtime/sessionContext.js";
|
||||
|
||||
import { getLocalAgentContext } from "../context/localContext.js";
|
||||
import { type ChatSessionBridge } from "../chat/sessionBridge.js";
|
||||
@@ -20,6 +23,8 @@ const abortPayloadSchema = z.object({
|
||||
});
|
||||
|
||||
type RegisterAuxiliaryRoutesOptions = {
|
||||
interactionRequestStore?: InteractionRequestStore;
|
||||
runtime?: OpencodeRuntimeAdapter;
|
||||
activeRuns: Map<string, ActiveRun>;
|
||||
lastRunStatuses: Map<string, RunStatus>;
|
||||
sessionBridge: ChatSessionBridge;
|
||||
@@ -37,6 +42,8 @@ const toSessionUiStateContext = (record: { sessionId: string; actorKey: string;
|
||||
export const registerChatAuxiliaryRoutes = (
|
||||
chatRouter: Router,
|
||||
{
|
||||
interactionRequestStore,
|
||||
runtime,
|
||||
activeRuns,
|
||||
lastRunStatuses,
|
||||
sessionBridge,
|
||||
@@ -70,6 +77,18 @@ export const registerChatAuxiliaryRoutes = (
|
||||
return;
|
||||
}
|
||||
frontendActionCoordinator.cancelSession(sessionRecord.sessionId);
|
||||
const runtimeContext = getRuntimeSessionContext(sessionRecord.sessionId);
|
||||
if (runtimeContext) setRuntimeSessionContext({ ...runtimeContext, hostQuestionClosed: true });
|
||||
activeRuns.get(sessionRecord.sessionId)?.controller.abort();
|
||||
await interactionRequestStore?.cancelPending(toSessionUiStateContext(sessionRecord));
|
||||
const pendingInteraction = await interactionRequestStore?.getBlocking(toSessionUiStateContext(sessionRecord));
|
||||
if (pendingInteraction?.delivery && runtime && interactionRequestStore) {
|
||||
const status = await runtime.stopInteractionInput(sessionRecord.sessionId, pendingInteraction.delivery.native_message_id);
|
||||
const latest = await interactionRequestStore.get(toSessionUiStateContext(sessionRecord), pendingInteraction.interaction_id);
|
||||
if (latest?.delivery && !["delivered", "cancelled"].includes(latest.delivery.status)) await interactionRequestStore.updateDelivery(toSessionUiStateContext(sessionRecord), latest.interaction_id, {
|
||||
expected_revision: latest.delivery.revision, status,
|
||||
});
|
||||
}
|
||||
activeRuns.get(sessionRecord.sessionId)?.inputs?.close();
|
||||
const binding = await sessionBridge.abort({
|
||||
clientSessionId: sessionRecord.sessionId,
|
||||
|
||||
@@ -5,14 +5,12 @@ import { z } from "zod";
|
||||
import { getLocalAgentContext, getTrustedProductCredential } from "../context/localContext.js";
|
||||
import { logger } from "../logger.js";
|
||||
import { type OpencodeRuntimeAdapter } from "../runtime/opencode.js";
|
||||
import { type SessionMetadataStore } from "../sessions/metadataStore.js";
|
||||
import { SessionContextMismatchError, type SessionMetadataStore } from "../sessions/metadataStore.js";
|
||||
import { type SessionUiStateStore } from "../sessions/uiStateStore.js";
|
||||
import { toActorKey, toProjectKey } from "../utils/fileStore.js";
|
||||
import {
|
||||
type ActiveRun,
|
||||
toPermissionStatus,
|
||||
updateLastAssistantPermission,
|
||||
updateLastAssistantQuestion,
|
||||
} from "./chatUiState.js";
|
||||
|
||||
const permissionReplyPayloadSchema = z.object({
|
||||
@@ -37,32 +35,7 @@ type RegisterInteractionRoutesOptions = {
|
||||
sessionUiStateStore: SessionUiStateStore;
|
||||
};
|
||||
|
||||
type ResolvedPendingQuestion = {
|
||||
requestId: string;
|
||||
question: NonNullable<ReturnType<ActiveRun["pendingQuestions"]["get"]>>;
|
||||
};
|
||||
|
||||
const toSessionUiStateContext = (sessionId: string) => ({
|
||||
sessionId,
|
||||
});
|
||||
|
||||
const resolvePendingQuestion = (
|
||||
run: ActiveRun,
|
||||
requestId: string,
|
||||
): ResolvedPendingQuestion | null => {
|
||||
const exactQuestion = run.pendingQuestions.get(requestId);
|
||||
if (exactQuestion) {
|
||||
return { requestId, question: exactQuestion };
|
||||
}
|
||||
|
||||
for (const [pendingRequestId, question] of run.pendingQuestions.entries()) {
|
||||
if (question.tool?.callID === requestId) {
|
||||
return { requestId: pendingRequestId, question };
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
const toSessionUiStateContext = (sessionId: string) => ({ sessionId });
|
||||
|
||||
export const registerChatInteractionRoutes = (
|
||||
chatRouter: Router,
|
||||
@@ -213,265 +186,80 @@ export const registerChatInteractionRoutes = (
|
||||
}
|
||||
});
|
||||
|
||||
chatRouter.post("/question/:request_id/reply", async (req, res) => {
|
||||
const requestId = req.params.request_id?.trim();
|
||||
const parsed = questionReplyPayloadSchema.safeParse(req.body);
|
||||
if (!requestId) {
|
||||
res.status(400).json({ message: "request_id is required" });
|
||||
return;
|
||||
}
|
||||
if (!parsed.success) {
|
||||
res.status(400).json({
|
||||
message: "invalid request payload",
|
||||
detail: parsed.error.flatten(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> => Boolean(value && typeof value === "object" && !Array.isArray(value));
|
||||
const questionHandler = (action: "status" | "reply" | "reject") => async (req: import("express").Request, res: import("express").Response) => {
|
||||
res.setHeader("Cache-Control", "private, no-store");
|
||||
const requestId = typeof req.params.request_id === "string" ? req.params.request_id.trim() : undefined;
|
||||
const parsed = action === "status"
|
||||
? questionRejectPayloadSchema.strict().safeParse({ session_id: req.params.session_id })
|
||||
: (action === "reply" ? questionReplyPayloadSchema.strict() : questionRejectPayloadSchema.strict()).safeParse(req.body);
|
||||
if (!requestId || requestId.length > 128 || !parsed.success) { res.status(400).json({ code: "QUESTION_INVALID", message: "invalid question request" }); return; }
|
||||
try {
|
||||
const authContext = getLocalAgentContext(req);
|
||||
const projectId = authContext.projectId;
|
||||
const userId = authContext.userId;
|
||||
const actorKey = authContext.actorKey;
|
||||
const projectKey = authContext.projectKey;
|
||||
const sessionRecord = await sessionMetadataStore.get(
|
||||
{ actorKey, projectId, projectKey, userId },
|
||||
parsed.data.session_id,
|
||||
);
|
||||
if (!sessionRecord) {
|
||||
res.status(404).json({ message: "session not found" });
|
||||
return;
|
||||
const session = await sessionMetadataStore.get(getLocalAgentContext(req), parsed.data.session_id);
|
||||
if (!session) { res.status(404).json({ code: "SESSION_NOT_FOUND", message: "session not found" }); return; }
|
||||
const context = { sessionId: session.sessionId, actorKey: session.actorKey, projectKey: session.projectKey };
|
||||
const saved = await sessionUiStateStore.read(context);
|
||||
const run = activeRuns.get(session.sessionId);
|
||||
// Only host-persisted questions grant access to the native form API.
|
||||
const candidates: Record<string, unknown>[] = [];
|
||||
for (const message of saved?.messages ?? []) {
|
||||
if (!isRecord(message)) continue;
|
||||
const historical = Array.isArray(message.questions) ? message.questions : [];
|
||||
const canonical = Array.isArray(message.parts) ? message.parts.flatMap((part: unknown) => isRecord(part) && part.type === "data-question_request" && isRecord(part.data) ? [{ ...part.data, requestId: part.data.request_id }] : []) : [];
|
||||
for (const q of [...historical, ...canonical]) if (isRecord(q) && (q.requestId === requestId || (isRecord(q.tool) && q.tool.callID === requestId))) candidates.push(q);
|
||||
}
|
||||
|
||||
const run = activeRuns.get(sessionRecord.sessionId);
|
||||
if (!run) {
|
||||
res.status(409).json({ message: "session is not waiting for questions" });
|
||||
return;
|
||||
const stored = candidates.find((q) => !isRecord(q.tool) || q.requestId !== q.tool.callID) ?? candidates[0];
|
||||
if (!stored || typeof stored.requestId !== "string") { res.status(404).json({ code: "QUESTION_NOT_FOUND", message: "question request not found" }); return; }
|
||||
const canonicalId = stored.requestId;
|
||||
let confirmedStatus = ["answered", "rejected", "invalidated"].includes(String(stored.status)) ? String(stored.status) : undefined;
|
||||
for (const message of saved?.messages ?? []) {
|
||||
if (!isRecord(message) || !Array.isArray(message.parts)) continue;
|
||||
for (const part of message.parts) {
|
||||
if (isRecord(part) && part.type === "data-question_response" && isRecord(part.data) && part.data.request_id === canonicalId) confirmedStatus = typeof part.data.status === "string" ? part.data.status : part.data.rejected === true ? "rejected" : "answered";
|
||||
}
|
||||
}
|
||||
|
||||
const pendingQuestion = resolvePendingQuestion(run, requestId);
|
||||
if (!pendingQuestion) {
|
||||
res.status(404).json({ message: "question request not found" });
|
||||
return;
|
||||
}
|
||||
const persistQuestionState = async () => {
|
||||
const currentState = await sessionUiStateStore.read(
|
||||
toSessionUiStateContext(sessionRecord.sessionId),
|
||||
);
|
||||
await sessionUiStateStore.write(toSessionUiStateContext(sessionRecord.sessionId), {
|
||||
sessionId: sessionRecord.sessionId,
|
||||
isTitleManuallyEdited: currentState?.isTitleManuallyEdited ?? false,
|
||||
messages: run.messages,
|
||||
// A host-confirmed reply remains authoritative after native forms are pruned.
|
||||
const nativeStatus = confirmedStatus ? undefined : await runtime.getQuestionStatus({ sessionId: session.sessionId, requestId: canonicalId });
|
||||
const status = confirmedStatus ?? (nativeStatus === "cancelled" ? "rejected" : nativeStatus === "missing" ? "invalidated" : nativeStatus!);
|
||||
const persist = async (nextStatus: string, answers?: string[][]) => {
|
||||
const update = (messages: unknown[]) => messages.map((message) => {
|
||||
if (!isRecord(message)) return message;
|
||||
const questions = Array.isArray(message.questions) ? message.questions.map((q: unknown) => isRecord(q) && q.requestId === canonicalId ? { ...q, status: nextStatus, ...(answers ? { answers } : {}), error: undefined } : q) : undefined;
|
||||
const parts = Array.isArray(message.parts) ? [...message.parts] : undefined;
|
||||
if (parts && nextStatus !== "pending" && parts.some((part: unknown) => isRecord(part) && part.type === "data-question_request" && isRecord(part.data) && part.data.request_id === canonicalId)) {
|
||||
const response = { type: "data-question_response", data: { session_id: session.sessionId, request_id: canonicalId, status: nextStatus, rejected: nextStatus === "rejected" || nextStatus === "invalidated", ...(answers ? { answers } : {}) } };
|
||||
const index = parts.findIndex((part: unknown) => isRecord(part) && part.type === "data-question_response" && isRecord(part.data) && part.data.request_id === canonicalId);
|
||||
if (index >= 0) parts[index] = { ...parts[index], data: { ...(isRecord(parts[index]) && isRecord(parts[index].data) ? parts[index].data : {}), ...response.data } }; else parts.push(response);
|
||||
}
|
||||
return { ...message, ...(questions ? { questions } : {}), ...(parts ? { parts } : {}) };
|
||||
});
|
||||
// Update the live projection before entering the same atomic persistence queue.
|
||||
if (run) { run.messages = update(run.messages); if (nextStatus !== "pending") run.pendingQuestions.delete(canonicalId); }
|
||||
await sessionUiStateStore.update(context, (latest) => ({ ...latest, messages: update(latest.messages) }));
|
||||
};
|
||||
|
||||
try {
|
||||
await runtime.replyQuestion({
|
||||
requestId: pendingQuestion.requestId,
|
||||
sessionId: sessionRecord.sessionId,
|
||||
answers: parsed.data.answers,
|
||||
});
|
||||
} catch (error) {
|
||||
run.messages = updateLastAssistantQuestion(
|
||||
run.messages,
|
||||
pendingQuestion.requestId,
|
||||
(question) => ({
|
||||
...question,
|
||||
status: "error",
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "failed to reply question",
|
||||
}),
|
||||
);
|
||||
await persistQuestionState().catch((persistError) => {
|
||||
logger.warn(
|
||||
{ err: persistError, sessionId: sessionRecord.sessionId },
|
||||
"failed to persist question error state",
|
||||
);
|
||||
});
|
||||
res.status(502).json({
|
||||
message: "question reply failed",
|
||||
detail: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
if (action === "status" || status !== "pending") {
|
||||
await persist(status);
|
||||
res.status(action === "status" ? 200 : 409).json({ session_id: session.sessionId, request_id: canonicalId, status, ...(action !== "status" ? { code: "QUESTION_RESOLVED" } : {}) });
|
||||
return;
|
||||
}
|
||||
|
||||
run.pendingQuestions.delete(pendingQuestion.requestId);
|
||||
run.messages = updateLastAssistantQuestion(
|
||||
run.messages,
|
||||
pendingQuestion.requestId,
|
||||
(question) => ({
|
||||
...question,
|
||||
status: "answered",
|
||||
answers: parsed.data.answers,
|
||||
repliedAt: Date.now(),
|
||||
error: undefined,
|
||||
}),
|
||||
);
|
||||
await persistQuestionState().catch((persistError) => {
|
||||
logger.warn(
|
||||
{ err: persistError, sessionId: sessionRecord.sessionId },
|
||||
"failed to persist question reply state",
|
||||
);
|
||||
});
|
||||
for (const subscriber of run.subscribers) {
|
||||
subscriber.write("question_response", {
|
||||
session_id: pendingQuestion.question.session_id,
|
||||
request_id: pendingQuestion.requestId,
|
||||
answers: parsed.data.answers,
|
||||
});
|
||||
}
|
||||
if (
|
||||
run.status !== "running" &&
|
||||
run.pendingPermissions.size === 0 &&
|
||||
run.pendingQuestions.size === 0
|
||||
) {
|
||||
activeRuns.delete(sessionRecord.sessionId);
|
||||
}
|
||||
|
||||
res.status(202).json({
|
||||
session_id: pendingQuestion.question.session_id,
|
||||
request_id: pendingQuestion.requestId,
|
||||
answers: parsed.data.answers,
|
||||
});
|
||||
const answers = action === "reply" ? questionReplyPayloadSchema.parse(req.body).answers : undefined;
|
||||
if (action === "reply") await runtime.replyQuestion({ sessionId: session.sessionId, requestId: canonicalId, answers: answers ?? [] });
|
||||
else await runtime.rejectQuestion({ sessionId: session.sessionId, requestId: canonicalId });
|
||||
await persist(action === "reply" ? "answered" : "rejected", answers);
|
||||
for (const subscriber of run?.subscribers ?? []) subscriber.write("question_response", { session_id: session.sessionId, request_id: canonicalId, ...(action === "reply" ? { answers } : { rejected: true }) });
|
||||
if (run && run.status !== "running" && !run.pendingPermissions.size && !run.pendingQuestions.size) activeRuns.delete(session.sessionId);
|
||||
res.status(202).json({ session_id: session.sessionId, request_id: canonicalId, ...(action === "reply" ? { answers } : { rejected: true }) });
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
logger.error({ err: error }, "question reply route failed");
|
||||
res.status(500).json({
|
||||
message: "question reply route failed",
|
||||
detail,
|
||||
});
|
||||
// Native connectivity failure is not proof that a historical question expired.
|
||||
if (error instanceof SessionContextMismatchError) { res.status(404).json({ code: "SESSION_NOT_FOUND", message: "session not found" }); return; }
|
||||
const tag = isRecord(error) ? error._tag : undefined;
|
||||
if (tag === "FormInvalidAnswerError") { res.status(400).json({ code: "QUESTION_INVALID_ANSWER", message: "invalid question answer" }); return; }
|
||||
if (tag === "FormAlreadySettledError" || tag === "FormNotFoundError") { res.status(409).json({ code: "QUESTION_RESOLVED", message: "question is no longer pending" }); return; }
|
||||
logger.warn({ err: error }, "legacy question reconciliation unavailable");
|
||||
res.status(503).json({ code: "QUESTION_UNAVAILABLE", message: "question state temporarily unavailable" });
|
||||
}
|
||||
});
|
||||
|
||||
chatRouter.post("/question/:request_id/reject", async (req, res) => {
|
||||
const requestId = req.params.request_id?.trim();
|
||||
const parsed = questionRejectPayloadSchema.safeParse(req.body);
|
||||
if (!requestId) {
|
||||
res.status(400).json({ message: "request_id is required" });
|
||||
return;
|
||||
}
|
||||
if (!parsed.success) {
|
||||
res.status(400).json({
|
||||
message: "invalid request payload",
|
||||
detail: parsed.error.flatten(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const authContext = getLocalAgentContext(req);
|
||||
const projectId = authContext.projectId;
|
||||
const userId = authContext.userId;
|
||||
const actorKey = authContext.actorKey;
|
||||
const projectKey = authContext.projectKey;
|
||||
const sessionRecord = await sessionMetadataStore.get(
|
||||
{ actorKey, projectId, projectKey, userId },
|
||||
parsed.data.session_id,
|
||||
);
|
||||
if (!sessionRecord) {
|
||||
res.status(404).json({ message: "session not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
const run = activeRuns.get(sessionRecord.sessionId);
|
||||
if (!run) {
|
||||
res.status(409).json({ message: "session is not waiting for questions" });
|
||||
return;
|
||||
}
|
||||
|
||||
const pendingQuestion = resolvePendingQuestion(run, requestId);
|
||||
if (!pendingQuestion) {
|
||||
res.status(404).json({ message: "question request not found" });
|
||||
return;
|
||||
}
|
||||
const persistQuestionState = async () => {
|
||||
const currentState = await sessionUiStateStore.read(
|
||||
toSessionUiStateContext(sessionRecord.sessionId),
|
||||
);
|
||||
await sessionUiStateStore.write(toSessionUiStateContext(sessionRecord.sessionId), {
|
||||
sessionId: sessionRecord.sessionId,
|
||||
isTitleManuallyEdited: currentState?.isTitleManuallyEdited ?? false,
|
||||
messages: run.messages,
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
await runtime.rejectQuestion({
|
||||
requestId: pendingQuestion.requestId,
|
||||
sessionId: sessionRecord.sessionId,
|
||||
});
|
||||
} catch (error) {
|
||||
run.messages = updateLastAssistantQuestion(
|
||||
run.messages,
|
||||
pendingQuestion.requestId,
|
||||
(question) => ({
|
||||
...question,
|
||||
status: "error",
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "failed to reject question",
|
||||
}),
|
||||
);
|
||||
await persistQuestionState().catch((persistError) => {
|
||||
logger.warn(
|
||||
{ err: persistError, sessionId: sessionRecord.sessionId },
|
||||
"failed to persist question error state",
|
||||
);
|
||||
});
|
||||
res.status(502).json({
|
||||
message: "question reject failed",
|
||||
detail: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
run.pendingQuestions.delete(pendingQuestion.requestId);
|
||||
run.messages = updateLastAssistantQuestion(
|
||||
run.messages,
|
||||
pendingQuestion.requestId,
|
||||
(question) => ({
|
||||
...question,
|
||||
status: "rejected",
|
||||
repliedAt: Date.now(),
|
||||
error: undefined,
|
||||
}),
|
||||
);
|
||||
await persistQuestionState().catch((persistError) => {
|
||||
logger.warn(
|
||||
{ err: persistError, sessionId: sessionRecord.sessionId },
|
||||
"failed to persist question reject state",
|
||||
);
|
||||
});
|
||||
for (const subscriber of run.subscribers) {
|
||||
subscriber.write("question_response", {
|
||||
session_id: pendingQuestion.question.session_id,
|
||||
request_id: pendingQuestion.requestId,
|
||||
rejected: true,
|
||||
});
|
||||
}
|
||||
if (
|
||||
run.status !== "running" &&
|
||||
run.pendingPermissions.size === 0 &&
|
||||
run.pendingQuestions.size === 0
|
||||
) {
|
||||
activeRuns.delete(sessionRecord.sessionId);
|
||||
}
|
||||
|
||||
res.status(202).json({
|
||||
session_id: pendingQuestion.question.session_id,
|
||||
request_id: pendingQuestion.requestId,
|
||||
rejected: true,
|
||||
});
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
logger.error({ err: error }, "question reject route failed");
|
||||
res.status(500).json({
|
||||
message: "question reject route failed",
|
||||
detail,
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
chatRouter.get("/session/:session_id/questions/:request_id", questionHandler("status"));
|
||||
chatRouter.post("/question/:request_id/reply", questionHandler("reply"));
|
||||
chatRouter.post("/question/:request_id/reject", questionHandler("reject"));
|
||||
};
|
||||
|
||||
@@ -5,6 +5,8 @@ import { type Router } from "express";
|
||||
import { z } from "zod";
|
||||
|
||||
import { type ChatSessionBridge } from "../chat/sessionBridge.js";
|
||||
import { projectInteractionReplyMessages } from "../chat/interaction-message-presentation.js";
|
||||
import type { InteractionRequestStore } from "../sessions/interaction-request-store.js";
|
||||
import { agentModelOptions } from "../chat/models.js";
|
||||
import { config } from "../config.js";
|
||||
import {
|
||||
@@ -76,6 +78,7 @@ const forkPayloadSchema = z.object({
|
||||
});
|
||||
|
||||
type RegisterChatSessionRoutesOptions = {
|
||||
interactionRequestStore?: InteractionRequestStore;
|
||||
learningOrchestrator: LearningOrchestrator;
|
||||
frontendActionCoordinator: FrontendActionCoordinator;
|
||||
runState: ChatRunState;
|
||||
@@ -100,6 +103,7 @@ export const registerChatSessionRoutes = (
|
||||
options: RegisterChatSessionRoutesOptions,
|
||||
) => {
|
||||
const {
|
||||
interactionRequestStore,
|
||||
frontendActionCoordinator,
|
||||
learningOrchestrator,
|
||||
runState,
|
||||
@@ -113,6 +117,17 @@ export const registerChatSessionRoutes = (
|
||||
const { activeRuns, lastRunStatuses } = runState;
|
||||
const getSessionRunStatus = runState.getStatus;
|
||||
|
||||
const withInteractionReplies = async (record: SessionRecord, messages: unknown[]) => {
|
||||
if (!interactionRequestStore) return messages;
|
||||
const ids = new Set(messages.flatMap((message) => {
|
||||
if (!message || typeof message !== "object") return [];
|
||||
const value = message as Record<string, unknown>;
|
||||
return value.role === "user" && typeof value.id === "string" ? [value.id] : [];
|
||||
}));
|
||||
const replies = await interactionRequestStore.forMessages(toSessionUiStateContext(record), ids);
|
||||
return projectInteractionReplyMessages(messages, replies);
|
||||
};
|
||||
|
||||
const refreshCollaborationTitle = async (record: SessionRecord, credential: ProductCredential | null, knownTaskTitle?: string, signal?: AbortSignal) => {
|
||||
if (readPurpose(record) !== "task_collaboration" || !record.taskId
|
||||
|| !isGenericTaskCollaborationTitle(record.title)) return record;
|
||||
@@ -603,8 +618,8 @@ export const registerChatSessionRoutes = (
|
||||
const messages = readPurpose(record) === "task_collaboration" ? state?.messages ?? []
|
||||
: activeRuns.get(record.sessionId)?.messages ?? state?.messages ?? [];
|
||||
try {
|
||||
res.json(sessionMessagePage(messages, { scope: historyScope([record.actorKey, record.projectKey, record.sessionId]),
|
||||
...query.data }));
|
||||
const page = sessionMessagePage(messages, { scope: historyScope([record.actorKey, record.projectKey, record.sessionId]), ...query.data });
|
||||
res.json({ ...page, messages: await withInteractionReplies(record, page.messages) });
|
||||
} catch (error) {
|
||||
if (!(error instanceof HistoryCursorError)) throw error;
|
||||
res.status(400).json({ message: error.message });
|
||||
@@ -659,6 +674,7 @@ export const registerChatSessionRoutes = (
|
||||
status: sessionRecord.status,
|
||||
session_id: sessionRecord.sessionId,
|
||||
...messagePayload,
|
||||
messages: await withInteractionReplies(sessionRecord, messagePayload.messages),
|
||||
execution_plans: state?.executionPlans ?? [],
|
||||
parent_session_id: sessionRecord.parentSessionId,
|
||||
is_streaming: activeRuns.get(sessionRecord.sessionId)?.status === "running",
|
||||
@@ -709,6 +725,7 @@ export const registerChatSessionRoutes = (
|
||||
toSse("state", {
|
||||
session_id: sessionRecord.sessionId,
|
||||
...messagePayload,
|
||||
messages: await withInteractionReplies(sessionRecord, messagePayload.messages),
|
||||
execution_plans: run ? undefined : state?.executionPlans,
|
||||
is_streaming: run?.status === "running",
|
||||
run_status: getSessionRunStatus(sessionRecord.sessionId),
|
||||
|
||||
@@ -47,6 +47,7 @@ import {
|
||||
normalizeTodoPriority,
|
||||
normalizeTodoStatus,
|
||||
normalizeToolParams,
|
||||
sanitizeToolProgressInput,
|
||||
normalizeToolStatus,
|
||||
type PermissionRequestPayload,
|
||||
type TodoUpdatePayload,
|
||||
@@ -70,6 +71,8 @@ export {
|
||||
export type ApprovalMode = "request" | "always";
|
||||
|
||||
type StreamPromptOptions = {
|
||||
interactionMessageId?: string;
|
||||
onHostQuestion?: (interactionId: string) => Promise<Record<string, unknown>>;
|
||||
onPromptDispatched?: () => void;
|
||||
onInboxEvent?: (type: string, data: { inboxID?: string; delivery?: "steer" | "queue" }) => void;
|
||||
waitForUserInput?: () => Promise<boolean>;
|
||||
@@ -98,6 +101,7 @@ type ProgressPayload = {
|
||||
status: ProgressStatus;
|
||||
title: string;
|
||||
detail?: string;
|
||||
tool?: { name: string; call_id?: string; input?: Record<string, unknown> };
|
||||
};
|
||||
|
||||
const getPermissionTarget = (metadata: unknown) => {
|
||||
@@ -158,10 +162,13 @@ export const streamPromptResponse = async ({
|
||||
onPromptDispatched,
|
||||
onInboxEvent,
|
||||
waitForUserInput,
|
||||
interactionMessageId,
|
||||
onHostQuestion,
|
||||
}: StreamPromptOptions): Promise<{
|
||||
aborted: boolean;
|
||||
failed: boolean;
|
||||
toolCallCount: number;
|
||||
waitingInput?: boolean;
|
||||
}> => {
|
||||
const eventStream = await runtime.subscribeEvents();
|
||||
const iterator = eventStream[Symbol.asyncIterator]();
|
||||
@@ -191,6 +198,7 @@ export const streamPromptResponse = async ({
|
||||
let promptSettled = false;
|
||||
let aborted = signal?.aborted ?? false;
|
||||
let failed = false;
|
||||
let waitingInput = false;
|
||||
let finalAnswer: string | undefined;
|
||||
const presentedAnswerIds = new Set<string>();
|
||||
let finalAnswerUiBlockIds: string[] = [];
|
||||
@@ -227,7 +235,7 @@ export const streamPromptResponse = async ({
|
||||
})
|
||||
: null;
|
||||
|
||||
const emitProgress = ({ id, phase, status, title, detail }: ProgressPayload) => {
|
||||
const emitProgress = ({ id, phase, status, title, detail, tool }: ProgressPayload) => {
|
||||
if (status === "running" && finalizedProgressIds.has(id)) {
|
||||
return;
|
||||
}
|
||||
@@ -246,6 +254,7 @@ export const streamPromptResponse = async ({
|
||||
status,
|
||||
title,
|
||||
detail,
|
||||
tool,
|
||||
started_at: startedAt,
|
||||
elapsed_ms: Math.max(0, now - startedAt),
|
||||
});
|
||||
@@ -262,6 +271,7 @@ export const streamPromptResponse = async ({
|
||||
status,
|
||||
title,
|
||||
detail,
|
||||
tool,
|
||||
started_at: startedAt,
|
||||
ended_at: now,
|
||||
duration_ms: durationMs,
|
||||
@@ -278,7 +288,15 @@ export const streamPromptResponse = async ({
|
||||
|
||||
const promptPromise = Promise.resolve().then(async () => {
|
||||
await beforePrompt?.();
|
||||
await runtime.prompt(sessionId, message, toRuntimeModel(model));
|
||||
if (interactionMessageId) {
|
||||
const result = await runtime.continueInteraction(sessionId, interactionMessageId, message, toRuntimeModel(model), signal);
|
||||
if (result === "completed") {
|
||||
const recovered = await runtime.readInteractionResult(sessionId, interactionMessageId);
|
||||
tokenSmoother.writeToken(recovered);
|
||||
emittedText = true;
|
||||
done = true;
|
||||
}
|
||||
} else await runtime.prompt(sessionId, message, toRuntimeModel(model));
|
||||
onPromptDispatched?.();
|
||||
})
|
||||
.then(() => {
|
||||
@@ -749,6 +767,30 @@ export const streamPromptResponse = async ({
|
||||
});
|
||||
}
|
||||
if (part.type === "tool") {
|
||||
if (part.tool === "ask_user") {
|
||||
// Reserve every lifecycle state before generic tool bookkeeping:
|
||||
// a running call must not consume its completed-question identity.
|
||||
if (part.state.status === "completed" && !emittedToolParts.has(part.id)) {
|
||||
const output = parseToolOutput(part.state.output);
|
||||
if (!onHostQuestion || !isObjectRecord(output) || typeof output.interaction_id !== "string") {
|
||||
throw new Error("Host question completed without a valid persisted interaction");
|
||||
}
|
||||
emittedToolParts.add(part.id);
|
||||
const record = await onHostQuestion(output.interaction_id);
|
||||
write("interaction_request", { ...record, session_id: clientSessionId });
|
||||
await runtime.abortSession(sessionId);
|
||||
await runtime.waitForSessionIdle(sessionId, undefined, true);
|
||||
waitingInput = true;
|
||||
done = true;
|
||||
} else if (part.state.status === "error") {
|
||||
emitProgress({
|
||||
id: part.id, phase: "tool", status: "error",
|
||||
title: getToolProgressTitle(part.tool, part.state.status),
|
||||
detail: part.state.error,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (part.tool === "present_answer") {
|
||||
if (suppressAnswerUi) continue;
|
||||
if (part.state.status === "completed" && !emittedToolParts.has(part.id)) {
|
||||
@@ -863,6 +905,7 @@ export const streamPromptResponse = async ({
|
||||
phase: "tool",
|
||||
status: normalizeToolStatus(part.state.status),
|
||||
title: getToolProgressTitle(part.tool, part.state.status),
|
||||
tool: { name: part.tool, call_id: part.callID, input: sanitizeToolProgressInput(toolParams) },
|
||||
detail: buildToolProgressDetail(
|
||||
part.tool,
|
||||
part.state.status,
|
||||
@@ -1035,6 +1078,12 @@ export const streamPromptResponse = async ({
|
||||
}
|
||||
|
||||
await promptPromise;
|
||||
if (waitingInput) {
|
||||
await tokenSmoother.flush();
|
||||
emitProgress({ id: "request-completed", phase: "question", status: "completed", title: "等待你的回答", detail: "问题已保存,可以切换会话后继续回答。" });
|
||||
write("done", { session_id: clientSessionId, reason: "waiting_input" });
|
||||
return { aborted: false, failed: false, toolCallCount, waitingInput: true };
|
||||
}
|
||||
if (!emittedText && !finalAnswer) {
|
||||
logDevelopmentDebug("no streamed text emitted, falling back to messages()", {
|
||||
...debugContext,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Event as OpencodeEvent, Part } from "@opencode-ai/sdk/v2";
|
||||
|
||||
import { containsForbiddenPersistentContent } from "../utils/persistencePolicy.js";
|
||||
import { logger } from "../logger.js";
|
||||
import { type QuestionAnswers } from "../runtime/opencode.js";
|
||||
|
||||
@@ -61,8 +62,8 @@ const isDevelopmentDebugLoggingEnabled = process.env.NODE_ENV === "development";
|
||||
|
||||
const toolLabels: Record<string, string> = {
|
||||
clear_scada_analysis: "清除 SCADA 地图分析",
|
||||
memory_manager: "记忆写入",
|
||||
render_scada_analysis: "渲染 SCADA 地图分析",
|
||||
memory_manager: "记忆写入",
|
||||
session_search: "历史会话检索",
|
||||
skill_manager: "流程沉淀",
|
||||
};
|
||||
@@ -356,6 +357,40 @@ export const normalizeToolStatus = (status: string) => {
|
||||
return "running";
|
||||
};
|
||||
|
||||
/** Browser-visible tool inputs: retain useful values without credentials or large payloads. */
|
||||
export const sanitizeToolProgressInput = (params: Record<string, unknown>): Record<string, unknown> => {
|
||||
let remaining = 16_000;
|
||||
let nodes = 512;
|
||||
const visit = (value: unknown, depth: number): unknown => {
|
||||
if (remaining <= 0 || nodes-- <= 0) return "[truncated]";
|
||||
if (typeof value === "string") {
|
||||
if (containsForbiddenPersistentContent(value)) return "[redacted]";
|
||||
const limit = Math.min(remaining, 4_000);
|
||||
remaining -= Math.min(value.length, limit);
|
||||
return value.length > limit ? `${value.slice(0, limit)}… [truncated]` : value;
|
||||
}
|
||||
if (value === null || typeof value === "boolean" || typeof value === "number") return value;
|
||||
if (depth >= 5) return "[truncated]";
|
||||
if (Array.isArray(value)) {
|
||||
const items = value.slice(0, 64).map((item) => visit(item, depth + 1));
|
||||
if (value.length > 64) items.push("[truncated]");
|
||||
return items;
|
||||
}
|
||||
if (value && typeof value === "object") {
|
||||
const entries = Object.entries(value).slice(0, 64).map(([rawKey, item]) => {
|
||||
const key = rawKey.slice(0, 100);
|
||||
remaining -= key.length;
|
||||
return [key, /authorization|cookie|password|secret|token|api[_-]?key|credential|headers?/i.test(key)
|
||||
? "[redacted]" : visit(item, depth + 1)];
|
||||
});
|
||||
if (Object.keys(value).length > 64) entries.push(["…", "[truncated]"]);
|
||||
return Object.fromEntries(entries);
|
||||
}
|
||||
return "[unsupported]";
|
||||
};
|
||||
return visit(params, 0) as Record<string, unknown>;
|
||||
};
|
||||
|
||||
const formatProgressValue = (value: unknown): string => {
|
||||
if (typeof value === "string") {
|
||||
return value.length > 120 ? `${value.slice(0, 117)}...` : value;
|
||||
@@ -435,11 +470,11 @@ export const buildToolProgressDetail = (
|
||||
error?: string,
|
||||
) => {
|
||||
const toolName = toolLabels[tool] ?? tool;
|
||||
const reasonText = reason ? `;说明:${reason}` : "";
|
||||
const paramsText = `;关键参数:${summarizeToolParams(params)}`;
|
||||
const reasonText = reason ? `;说明:${containsForbiddenPersistentContent(reason) ? "[redacted]" : reason}` : "";
|
||||
const paramsText = `;关键参数:${summarizeToolParams(sanitizeToolProgressInput(params))}`;
|
||||
|
||||
if (status === "error") {
|
||||
const errorText = error ? `;错误:${error}` : "";
|
||||
const errorText = error ? `;错误:${containsForbiddenPersistentContent(error) ? "[redacted]" : error}` : "";
|
||||
return `${toolName} 调用失败${reasonText}${paramsText}${errorText}`;
|
||||
}
|
||||
if (status === "completed") {
|
||||
|
||||
+108
-12
@@ -1,5 +1,9 @@
|
||||
import { createResponsesProjection, responseRequestSchema, responsePrompt } from "../chat/responses-protocol.js";
|
||||
import { ResponseInputTracker } from "../chat/response-inputs.js";
|
||||
import { ResponseInputTracker, WaitingResponseInputQueue } from "../chat/response-inputs.js";
|
||||
import { reconcileInteractionDelivery } from "../sessions/interaction-delivery-reconciliation.js";
|
||||
import type { InteractionRequestStore } from "../sessions/interaction-request-store.js";
|
||||
import { renderInteractionAnswer, type InteractionRequest } from "../chat/interaction-request-contract.js";
|
||||
import { createInteractionReplyMessage } from "../chat/interaction-message-presentation.js";
|
||||
import { createAnswerStreamProjection } from "../chat/answer-stream-projection.js";
|
||||
import type { AgentMessageCoordinator } from "../chat/agent-message-coordinator.js";
|
||||
import { credentialRefreshCoordinator } from "../product/credential-refresh.js";
|
||||
@@ -158,9 +162,9 @@ export const toCollaborationMessages = (messages: UIMessage[]) =>
|
||||
messages.flatMap((message) => {
|
||||
if (message.role !== "user" && message.role !== "assistant") return [];
|
||||
const content = readUiMessageText(message);
|
||||
const reference = message.parts.filter((part) => part.type === "data-task_context");
|
||||
const reference = message.parts.filter((part) => part.type === "data-task_context" || part.type === "data-interaction_reply");
|
||||
const rich = message.parts.some((part) => part.type === "data-answer_ui" || part.type === "data-final_answer");
|
||||
const parts = rich ? message.parts.filter((part) => ["text", "data-answer_ui", "data-final_answer", "data-task_context"].includes(part.type)) : [{ type: "text" as const, text: content }, ...reference];
|
||||
const parts = rich ? message.parts.filter((part) => ["text", "data-answer_ui", "data-final_answer", "data-task_context", "data-interaction_reply"].includes(part.type)) : [{ type: "text" as const, text: content }, ...reference];
|
||||
return content || rich ? [{ ...message, parts } as UIMessage] : [];
|
||||
});
|
||||
|
||||
@@ -248,6 +252,7 @@ export const buildFailedTaskExecutionPlan = (history: Record<string, unknown>[])
|
||||
};
|
||||
|
||||
type RegisterChatStreamRouteOptions = {
|
||||
interactionRequestStore?: InteractionRequestStore;
|
||||
agentMessages?: AgentMessageCoordinator;
|
||||
frontendActionCoordinator: FrontendActionCoordinator;
|
||||
learningOrchestrator: LearningOrchestrator;
|
||||
@@ -263,6 +268,7 @@ type RegisterChatStreamRouteOptions = {
|
||||
export const registerChatStreamRoute = (
|
||||
chatRouter: Router,
|
||||
{
|
||||
interactionRequestStore,
|
||||
frontendActionCoordinator,
|
||||
learningOrchestrator,
|
||||
memoryStore,
|
||||
@@ -276,6 +282,7 @@ export const registerChatStreamRoute = (
|
||||
}: RegisterChatStreamRouteOptions,
|
||||
) => {
|
||||
const { activeRuns, lastRunStatuses } = runState;
|
||||
const waitingInputs = new WaitingResponseInputQueue(runtime);
|
||||
const startingSessions = new Set<string>();
|
||||
chatRouter.post("/responses", async (req, res) => {
|
||||
const request = responseRequestSchema.safeParse(req.body);
|
||||
@@ -300,6 +307,9 @@ export const registerChatStreamRoute = (
|
||||
let wire: ReturnType<typeof createResponsesProjection> | undefined;
|
||||
let boundOperationalSessionId: string | undefined;
|
||||
let startingSessionId: string | undefined;
|
||||
let continuation: InteractionRequest | null = null;
|
||||
let continuationScope: ReturnType<typeof toSessionUiStateContext> | undefined;
|
||||
let waitingInteraction: InteractionRequest | null = null;
|
||||
try {
|
||||
const authContext = getLocalAgentContext(req);
|
||||
const projectId = authContext.projectId;
|
||||
@@ -311,7 +321,7 @@ export const registerChatStreamRoute = (
|
||||
const requestUiMessages = toUiMessages(parsed.data.messages);
|
||||
const requestPromptText =
|
||||
parsed.data.message?.trim() || readLatestUserText(requestUiMessages);
|
||||
const promptText = trigger
|
||||
let promptText = trigger
|
||||
? buildAgentTriggerPrompt(trigger)
|
||||
: requestPromptText;
|
||||
if (!promptText) {
|
||||
@@ -348,8 +358,49 @@ export const registerChatStreamRoute = (
|
||||
res.status(409).json({ message: "trigger does not match session" });
|
||||
return;
|
||||
}
|
||||
if (input.tjwater.interaction_id && (!existingSessionRecord || !interactionRequestStore || trigger || input.tjwater.delivery || input.tjwater.input_id)) {
|
||||
res.status(400).json({ message: "Interaction continuation requires an existing conversation" }); return;
|
||||
}
|
||||
if (existingSessionRecord && interactionRequestStore) {
|
||||
continuationScope = toSessionUiStateContext(existingSessionRecord);
|
||||
if (input.tjwater.interaction_id) {
|
||||
continuation = await interactionRequestStore.get(continuationScope, input.tjwater.interaction_id);
|
||||
if (!continuation?.delivery || !["answered", "skipped"].includes(continuation.status) || continuation.delivery.status === "cancelled") {
|
||||
res.status(409).json({ code: "INTERACTION_NOT_READY", message: "Question has no answer waiting to continue" }); return;
|
||||
}
|
||||
promptText = renderInteractionAnswer(continuation);
|
||||
}
|
||||
const blocking = await interactionRequestStore.getBlocking(continuationScope);
|
||||
waitingInteraction = blocking ? await reconcileInteractionDelivery(continuationScope, blocking, interactionRequestStore, runtime,
|
||||
activeRuns.get(existingSessionRecord.sessionId)?.status === "running") : null;
|
||||
if (continuation && waitingInteraction?.interaction_id === continuation.interaction_id) continuation = waitingInteraction;
|
||||
if (waitingInteraction?.delivery?.status === "delivered") waitingInteraction = null;
|
||||
if (waitingInteraction && waitingInteraction.interaction_id !== continuation?.interaction_id
|
||||
&& !(input.tjwater.delivery && input.tjwater.input_id && !input.stream && !trigger && readPurpose(existingSessionRecord) !== "task_execution")) {
|
||||
res.status(409).json({ code: "INTERACTION_PENDING", message: "请先回答或取消当前提问。" }); return;
|
||||
}
|
||||
if (continuation?.delivery?.status === "delivered") {
|
||||
// An acknowledged retry must not append or reorder persisted conversation messages.
|
||||
const text = await runtime.readInteractionResult(existingSessionRecord.sessionId, continuation.delivery.native_message_id);
|
||||
if (input.stream) {
|
||||
res.setHeader("Content-Type", "text/event-stream; charset=utf-8");
|
||||
res.setHeader("Cache-Control", "private, no-store");
|
||||
}
|
||||
const replay = createResponsesProjection({ model: parsed.data.model ?? config.OPENCODE_MODEL, conversationId: existingSessionRecord.sessionId,
|
||||
emit: (event) => { if (input.stream) res.write(`event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`); },
|
||||
});
|
||||
replay.write({ type: "start", messageId: `answer_${continuation.delivery.native_message_id}` });
|
||||
replay.write({ type: "text-start", id: "recovered-answer" });
|
||||
replay.write({ type: "text-delta", id: "recovered-answer", delta: text });
|
||||
replay.write({ type: "text-end", id: "recovered-answer" });
|
||||
replay.finish();
|
||||
if (input.stream) res.end(); else res.json(replay.response);
|
||||
return;
|
||||
}
|
||||
}
|
||||
const running = existingSessionRecord ? activeRuns.get(existingSessionRecord.sessionId) : undefined;
|
||||
if (running?.status === "running") {
|
||||
if (continuation) { res.status(409).json({ code: "INTERACTION_TURN_BUSY", message: "答案已保存,请等待当前轮次结束后继续。" }); return; }
|
||||
if (!input.tjwater.delivery || !input.tjwater.input_id || input.stream || trigger || readPurpose(existingSessionRecord!) === "task_execution") {
|
||||
res.status(409).json({ message: "Running conversations require a non-streaming steer/queue input with input_id" });
|
||||
return;
|
||||
@@ -362,8 +413,16 @@ export const registerChatStreamRoute = (
|
||||
res.status(202).json({ object: "tjwater.response_input", session_id: existingSessionRecord!.sessionId, ...receipt });
|
||||
return;
|
||||
}
|
||||
if (waitingInteraction && !continuation && existingSessionRecord && input.tjwater.delivery && input.tjwater.input_id && !input.stream && !trigger
|
||||
&& readPurpose(existingSessionRecord) !== "task_execution") {
|
||||
const receipt = await waitingInputs.submit(existingSessionRecord.sessionId, {
|
||||
id: input.tjwater.input_id, text: promptText, delivery: input.tjwater.delivery, status: "pending"
|
||||
});
|
||||
res.status(202).json({ object: "tjwater.response_input", session_id: existingSessionRecord.sessionId, ...receipt });
|
||||
return;
|
||||
}
|
||||
if (input.tjwater.delivery || input.tjwater.input_id) {
|
||||
res.status(409).json({ message: "The response has finished; send a new response instead" });
|
||||
res.status(409).json({ code: "RESPONSE_FINISHED", message: "The response has finished; send a new response instead" });
|
||||
return;
|
||||
}
|
||||
if (
|
||||
@@ -386,6 +445,7 @@ export const registerChatStreamRoute = (
|
||||
}
|
||||
|
||||
const { binding, requestContext, created } = await sessionBridge.resolve({
|
||||
preserveExecution: Boolean(continuation),
|
||||
sessionId: requestedSessionId,
|
||||
network: authContext.network,
|
||||
projectId,
|
||||
@@ -458,7 +518,7 @@ export const registerChatStreamRoute = (
|
||||
toSessionUiStateContext(activeSessionRecord),
|
||||
);
|
||||
const persistedUiMessages = toUiMessages(initialSessionState?.messages);
|
||||
if (input.tjwater.client_message_id && persistedUiMessages.some((message) => message.id === input.tjwater.client_message_id)) {
|
||||
if (!continuation && input.tjwater.client_message_id && persistedUiMessages.some((message) => message.id === input.tjwater.client_message_id)) {
|
||||
res.status(409).json({ message: "client_message_id already exists in this conversation" });
|
||||
return;
|
||||
}
|
||||
@@ -469,7 +529,14 @@ export const registerChatStreamRoute = (
|
||||
const visibleRequestMessages = sessionPurpose === "task_collaboration"
|
||||
? toCollaborationMessages(requestUiMessages)
|
||||
: requestUiMessages;
|
||||
const requestMessages =
|
||||
const continuationAssistantId = continuation ? `answer_${continuation.delivery!.native_message_id}` : undefined;
|
||||
const continuationUserExists = continuation && visiblePersistedMessages.some((message) => message.id === continuation!.delivery!.native_message_id);
|
||||
const continuationReply = continuation ? createInteractionReplyMessage(continuation) : undefined;
|
||||
const requestMessages = continuationReply
|
||||
? [...visiblePersistedMessages.filter((message) => message.id !== continuationAssistantId)
|
||||
.map((message) => message.id === continuationReply.id ? { ...message, ...continuationReply } : message),
|
||||
...(continuationUserExists ? [] : [continuationReply])]
|
||||
:
|
||||
visibleRequestMessages.length > 0
|
||||
? visibleRequestMessages
|
||||
: [
|
||||
@@ -531,7 +598,8 @@ export const registerChatStreamRoute = (
|
||||
startingSessionId = undefined;
|
||||
lastRunStatuses.set(clientSessionId, "running");
|
||||
const sessionUiStateContext = toSessionUiStateContext(activeSessionRecord);
|
||||
const assistantUiMessageId = `msg_${randomUUID().replaceAll("-", "")}`;
|
||||
const assistantUiMessageId = continuationAssistantId ?? `msg_${randomUUID().replaceAll("-", "")}`;
|
||||
setRuntimeSessionContext({ ...getRuntimeSessionContext(binding.sessionId)!, hostTurnId: wire.response.id, hostMessageId: assistantUiMessageId });
|
||||
const syncLatestUiMessages = () => {
|
||||
latestUiMessages = [
|
||||
...requestMessages,
|
||||
@@ -637,14 +705,14 @@ export const registerChatStreamRoute = (
|
||||
progress: upsertBackendProgress(message.progress, data),
|
||||
}));
|
||||
} else if (event === "done") {
|
||||
activeRun.status = "completed";
|
||||
lastRunStatuses.set(clientSessionId, "completed");
|
||||
activeRun.status = data.reason === "waiting_input" ? "waiting_input" : "completed";
|
||||
lastRunStatuses.set(clientSessionId, activeRun.status);
|
||||
activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({
|
||||
...message,
|
||||
content:
|
||||
typeof message.content === "string" && message.content.trim()
|
||||
? message.content
|
||||
: "Agent 已完成处理,但没有生成文本回答。请查看过程记录,或换个更具体的问题重试。",
|
||||
: data.reason === "waiting_input" ? "等待你的回答。" : "Agent 已完成处理,但没有生成文本回答。请查看过程记录,或换个更具体的问题重试。",
|
||||
progress: completeBackendProgress(message.progress),
|
||||
}));
|
||||
} else if (event === "error") {
|
||||
@@ -903,7 +971,18 @@ export const registerChatStreamRoute = (
|
||||
publish("user_input", { session_id: clientSessionId, ...receipt });
|
||||
});
|
||||
activeRun.inputs = inputTracker;
|
||||
if (continuation && interactionRequestStore && continuationScope && continuation.delivery?.status !== "delivered") {
|
||||
continuation = await interactionRequestStore.claimDelivery(continuationScope, continuation.interaction_id, {
|
||||
expected_revision: continuation.delivery!.revision, response_id: wire.response.id,
|
||||
});
|
||||
}
|
||||
const streamResult = await streamPromptResponse({
|
||||
interactionMessageId: continuation?.delivery?.native_message_id,
|
||||
onHostQuestion: interactionRequestStore ? async (id) => {
|
||||
const record = await interactionRequestStore.get(sessionUiStateContext, id);
|
||||
if (!record || record.turn_id !== wire!.response.id || record.message_id !== assistantUiMessageId) throw new Error("Question does not belong to this turn");
|
||||
return { ...record };
|
||||
} : undefined,
|
||||
runtime,
|
||||
sessionId: binding.sessionId,
|
||||
clientSessionId,
|
||||
@@ -1021,7 +1100,16 @@ export const registerChatStreamRoute = (
|
||||
logger.warn({ err: error, sessionId: clientSessionId }, "failed to persist chat stream state");
|
||||
});
|
||||
|
||||
if (!streamResult.aborted && !streamResult.failed) {
|
||||
if (continuation && interactionRequestStore && continuationScope) continuation = await interactionRequestStore.get(continuationScope, continuation.interaction_id);
|
||||
if (continuation && interactionRequestStore && continuationScope && continuation.delivery && !["delivered", "cancelled"].includes(continuation.delivery.status)) {
|
||||
const nativeState = await runtime.agentMessageState(binding.sessionId, continuation.delivery!.native_message_id);
|
||||
continuation = await interactionRequestStore.updateDelivery(continuationScope, continuation.interaction_id, {
|
||||
expected_revision: continuation.delivery!.revision, status: nativeState === "delivered" ? "delivered" : "blocked",
|
||||
response_id: wire.response.id, ...(nativeState !== "delivered" ? { error_code: "CONTINUATION_NOT_CONSUMED" } : {}),
|
||||
});
|
||||
publish("interaction_request", { ...continuation, session_id: clientSessionId });
|
||||
}
|
||||
if (!streamResult.aborted && !streamResult.failed && !streamResult.waitingInput) {
|
||||
const messages = await runtime.messages(binding.sessionId, 60);
|
||||
const assistantMessage = [...messages]
|
||||
.reverse()
|
||||
@@ -1188,6 +1276,14 @@ export const registerChatStreamRoute = (
|
||||
if (boundOperationalSessionId) {
|
||||
releaseOperationalTaskCredential(boundOperationalSessionId);
|
||||
}
|
||||
if (continuation && continuationScope && interactionRequestStore) {
|
||||
try {
|
||||
const latest = await interactionRequestStore.get(continuationScope, continuation.interaction_id);
|
||||
if (latest?.delivery && !["delivered", "cancelled"].includes(latest.delivery.status)) await interactionRequestStore.updateDelivery(continuationScope, latest.interaction_id, {
|
||||
expected_revision: latest.delivery.revision, status: "blocked", error_code: "CONTINUATION_UNCONFIRMED", response_id: latest.delivery.response_id,
|
||||
});
|
||||
} catch { /* Keep the durable input identity even when state storage is unavailable. */ }
|
||||
}
|
||||
logger.error({ err: error }, "chat stream failed");
|
||||
if (wire) {
|
||||
wire.write({ type: "error", errorText: "Agent response failed" });
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
type TodoUpdatePayload,
|
||||
} from "./chatStream.js";
|
||||
|
||||
export type RunStatus = "running" | "completed" | "error" | "aborted";
|
||||
export type RunStatus = "running" | "waiting_input" | "completed" | "error" | "aborted";
|
||||
|
||||
export type StreamSubscriber = {
|
||||
write: (event: string, data: Record<string, unknown>) => void;
|
||||
@@ -82,8 +82,13 @@ export const upsertBackendProgress = (
|
||||
const next = Array.isArray(progress) ? [...progress] : [];
|
||||
const id = typeof payload.id === "string" ? payload.id : `progress-${Date.now()}`;
|
||||
const index = next.findIndex((item) => isObjectRecord(item) && item.id === id);
|
||||
const existing = index >= 0 && isObjectRecord(next[index]) ? next[index] : undefined;
|
||||
const tool = isObjectRecord(payload.tool) && typeof payload.tool.name === "string"
|
||||
? { name: payload.tool.name, callId: typeof payload.tool.call_id === "string" ? payload.tool.call_id : undefined, input: isObjectRecord(payload.tool.input) ? payload.tool.input : undefined }
|
||||
: existing?.tool;
|
||||
const nextItem = {
|
||||
id,
|
||||
tool,
|
||||
phase: typeof payload.phase === "string" ? payload.phase : "progress",
|
||||
status:
|
||||
payload.status === "completed" || payload.status === "error"
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { Request, Response, Router } from "express";
|
||||
import { z } from "zod";
|
||||
import { getLocalAgentContext } from "../context/localContext.js";
|
||||
import { InteractionRequestError, type InteractionRequestStore } from "../sessions/interaction-request-store.js";
|
||||
import { SessionContextMismatchError, type SessionMetadataStore } from "../sessions/metadataStore.js";
|
||||
import type { OpencodeRuntimeAdapter } from "../runtime/opencode.js";
|
||||
import { reconcileInteractionDelivery } from "../sessions/interaction-delivery-reconciliation.js";
|
||||
|
||||
const identifier = z.string().min(1).max(128);
|
||||
const listQuerySchema = z.object({
|
||||
limit: z.string().regex(/^[1-9]\d?$/).transform(Number).pipe(z.number().max(50)).optional(),
|
||||
cursor: identifier.optional(),
|
||||
}).strict();
|
||||
|
||||
type Options = { sessions: SessionMetadataStore; interactions: InteractionRequestStore;
|
||||
runtime?: Pick<OpencodeRuntimeAdapter, "agentMessageState" | "readInteractionResult">;
|
||||
isRunning?: (sessionId: string) => boolean };
|
||||
|
||||
/** These routes only persist user intent. Authorized /responses owns model continuation. */
|
||||
export function registerInteractionRequestRoutes(router: Router, options: Options) {
|
||||
const handle = (operation: "list" | "get" | "draft" | "reply" | "skip" | "cancel" | "resume") => async (req: Request, res: Response) => {
|
||||
res.setHeader("Cache-Control", "private, no-store");
|
||||
try {
|
||||
const sessionId = identifier.parse(req.params.session_id);
|
||||
const session = await options.sessions.get(getLocalAgentContext(req), sessionId);
|
||||
if (!session) { res.status(404).json({ code: "SESSION_NOT_FOUND", detail: "session not found" }); return; }
|
||||
const context = { sessionId: session.sessionId, actorKey: session.actorKey, projectKey: session.projectKey };
|
||||
if (operation === "list") {
|
||||
const page = await options.interactions.list(context, listQuerySchema.parse(req.query));
|
||||
const items = await Promise.all(page.items.map((record) => reconcileInteractionDelivery(context, record, options.interactions, options.runtime, options.isRunning?.(sessionId))));
|
||||
res.json({ ...page, items });
|
||||
return;
|
||||
}
|
||||
const interactionId = identifier.parse(req.params.interaction_id);
|
||||
if (operation === "get") {
|
||||
if (Object.keys(req.query).length) throw new InteractionRequestError("invalid_query", 400);
|
||||
const record = await options.interactions.get(context, interactionId);
|
||||
if (!record) { res.status(404).json({ code: "interaction_not_found", detail: "interaction not found" }); return; }
|
||||
res.json(await reconcileInteractionDelivery(context, record, options.interactions, options.runtime, options.isRunning?.(sessionId)));
|
||||
return;
|
||||
}
|
||||
if (Object.keys(req.query).length) throw new InteractionRequestError("invalid_query", 400);
|
||||
const record = operation === "draft"
|
||||
? await options.interactions.updateDraft(context, interactionId, req.body)
|
||||
: await options.interactions[operation](context, interactionId, req.body);
|
||||
// A queued or blocked delivery is a successful saved answer, never an inference error.
|
||||
res.json(record);
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) { res.status(400).json({ code: "INTERACTION_INVALID", detail: "invalid interaction request" }); return; }
|
||||
if (error instanceof InteractionRequestError) { res.status(error.status).json({ code: error.code, detail: error.message, ...(error.current ? { current: error.current } : {}) }); return; }
|
||||
if (error instanceof SessionContextMismatchError) { res.status(404).json({ code: "SESSION_NOT_FOUND", detail: "session not found" }); return; }
|
||||
res.status(503).json({ code: "INTERACTION_UNAVAILABLE", detail: "interaction unavailable" });
|
||||
}
|
||||
};
|
||||
const base = "/session/:session_id/interactions";
|
||||
router.get(base, handle("list"));
|
||||
router.get(`${base}/:interaction_id`, handle("get"));
|
||||
router.put(`${base}/:interaction_id/draft`, handle("draft"));
|
||||
for (const operation of ["reply", "skip", "cancel", "resume"] as const) router.post(`${base}/:interaction_id/${operation}`, handle(operation));
|
||||
}
|
||||
+84
-6
@@ -164,7 +164,7 @@ export class OpencodeRuntimeAdapter {
|
||||
return response.output as { id: string; direct: boolean; sandboxed: boolean; workspaceRoot?: string }[] | undefined;
|
||||
};
|
||||
let tools = await readTools();
|
||||
const required = [...LOCAL_QUERY_TOOL_IDS, ...(config.TJWATER_MCP_ENABLED ? PRODUCT_MCP_TOOL_IDS : []), "shell", "final_answer", "present_answer", "locate_features", "zoom_to_map", "show_chart", "view_history", "memory_manager", "skill_manager", "render_scada_analysis", "clear_scada_analysis", "publish_execution_plan", "publish_task_report", "publish_task_progress", "publish_task_presentation", "propose_task_action", "add_task_human_evidence", "request_task_data_access", "todowrite", "send_task_message", "reply_agent_message"];
|
||||
const required = [...LOCAL_QUERY_TOOL_IDS, ...(config.TJWATER_MCP_ENABLED ? PRODUCT_MCP_TOOL_IDS : []), "shell", "ask_user", "final_answer", "present_answer", "locate_features", "zoom_to_map", "show_chart", "view_history", "memory_manager", "skill_manager", "render_scada_analysis", "clear_scada_analysis", "publish_execution_plan", "publish_task_report", "publish_task_progress", "publish_task_presentation", "propose_task_action", "add_task_human_evidence", "request_task_data_access", "todowrite", "send_task_message", "reply_agent_message"];
|
||||
let missing = required.filter((name) => !tools?.some((tool) => tool.id === name && tool.direct));
|
||||
const catalogDeadline = Date.now() + 10_000;
|
||||
// Native MCP connection/catalog publication can finish after plugin setup.
|
||||
@@ -362,10 +362,10 @@ export class OpencodeRuntimeAdapter {
|
||||
description: "调查协作消息(非用户直接指令)", metadata, delivery: "steer", resume: false });
|
||||
}
|
||||
|
||||
async enqueueUserInput(sessionId: string, input: ResponseInput) {
|
||||
async enqueueUserInput(sessionId: string, input: ResponseInput, options: { resume?: boolean } = {}) {
|
||||
const client = await this.ensureClient();
|
||||
await client.session.prompt({ sessionID: sessionId, id: input.id, text: input.text,
|
||||
delivery: input.delivery, resume: true, metadata: { source: "tjwater_user_input" } });
|
||||
delivery: input.delivery, resume: options.resume ?? true, metadata: { source: "tjwater_user_input" } });
|
||||
}
|
||||
|
||||
async listPendingUserInputs(sessionId: string): Promise<ResponseInput[]> {
|
||||
@@ -416,7 +416,7 @@ export class OpencodeRuntimeAdapter {
|
||||
return { ...item, status: "cancelled" };
|
||||
}
|
||||
|
||||
async prompt(sessionId: string, text: string, model?: RuntimeModelOverride) {
|
||||
async prompt(sessionId: string, text: string, model?: RuntimeModelOverride, messageId?: string) {
|
||||
const client = await this.ensureClient();
|
||||
const startedAt = Date.now();
|
||||
logDevelopmentDebug(
|
||||
@@ -433,7 +433,7 @@ export class OpencodeRuntimeAdapter {
|
||||
const agent = context?.purpose === "task_execution" && context.taskId && context.projectId ? TASK_AGENT : "instruction";
|
||||
await client.session.switchAgent({ sessionID: sessionId, agent });
|
||||
if (model) await client.session.switchModel({ sessionID: sessionId, model: { providerID: model.providerID, id: model.modelID } });
|
||||
await client.session.prompt({ sessionID: sessionId, text });
|
||||
await client.session.prompt({ sessionID: sessionId, text, ...(messageId ? { id: messageId, delivery: "steer" as const, metadata: { source: "tjwater_host_answer" } } : {}) });
|
||||
logDevelopmentDebug(
|
||||
"opencode session.prompt returned",
|
||||
{
|
||||
@@ -443,12 +443,79 @@ export class OpencodeRuntimeAdapter {
|
||||
);
|
||||
}
|
||||
|
||||
private interactionOperations?: Map<string, Promise<unknown>>;
|
||||
private async serializeInteraction<T>(sessionId: string, operation: () => Promise<T>): Promise<T> {
|
||||
const operations = this.interactionOperations ??= new Map();
|
||||
const pending = (operations.get(sessionId) ?? Promise.resolve()).catch(() => undefined).then(operation);
|
||||
operations.set(sessionId, pending);
|
||||
try { return await pending; }
|
||||
finally { if (operations.get(sessionId) === pending) operations.delete(sessionId); }
|
||||
}
|
||||
|
||||
async stopInteractionInput(sessionId: string, messageId: string): Promise<"delivered" | "cancelled"> {
|
||||
return this.serializeInteraction(sessionId, async () => {
|
||||
const client = await this.ensureClient();
|
||||
const state = await this.agentMessageState(sessionId, messageId);
|
||||
if (state === "delivered") return "delivered";
|
||||
if (state === "enqueued") await client.session.inbox.cancel({ sessionID: sessionId, inboxID: messageId });
|
||||
return await this.agentMessageState(sessionId, messageId) === "delivered" ? "delivered" : "cancelled";
|
||||
});
|
||||
}
|
||||
|
||||
async continueInteraction(sessionId: string, messageId: string, text: string, model?: RuntimeModelOverride, signal?: AbortSignal): Promise<"started" | "observing" | "completed"> {
|
||||
return this.serializeInteraction(sessionId, async () => {
|
||||
signal?.throwIfAborted();
|
||||
const client = await this.ensureClient();
|
||||
const state = await this.agentMessageState(sessionId, messageId);
|
||||
const active = Boolean((await client.session.active())[sessionId]);
|
||||
signal?.throwIfAborted();
|
||||
if (state === "missing") {
|
||||
if (active) throw new Error("Previous native turn is still active");
|
||||
await this.prompt(sessionId, text, model, messageId);
|
||||
return "started";
|
||||
}
|
||||
if (active) return "observing";
|
||||
if (state === "enqueued") {
|
||||
// Resume an admitted steering input without constructing another message.
|
||||
await client.session.interrupt({ sessionID: sessionId, resume: true });
|
||||
return "observing";
|
||||
}
|
||||
return "completed";
|
||||
});
|
||||
}
|
||||
|
||||
async messages(sessionId: string, limit = 20) {
|
||||
const client = await this.ensureClient();
|
||||
const messages = await client.message.list({ sessionID: sessionId, limit, order: "desc" });
|
||||
return messages.data.reverse().map((message) => normalizeRuntimeMessage(message, sessionId));
|
||||
}
|
||||
|
||||
async readInteractionResult(sessionId: string, messageId: string): Promise<string> {
|
||||
const client = await this.ensureClient();
|
||||
const messages = (await client.message.list({ sessionID: sessionId, limit: 100, order: "desc" })).data.slice().reverse();
|
||||
const start = messages.findIndex((message) => message.id === messageId && message.type === "user");
|
||||
if (start < 0) throw new Error("Saved answer result is outside the recovery window");
|
||||
const tail = messages.slice(start + 1);
|
||||
const nextUser = tail.findIndex((message) => message.type === "user");
|
||||
const related = nextUser < 0 ? tail : tail.slice(0, nextUser);
|
||||
for (const message of related.slice().reverse()) {
|
||||
if (message.type !== "assistant" || message.error) continue;
|
||||
for (const content of message.content.slice().reverse()) {
|
||||
if (content.type === "tool" && content.name === "final_answer" && content.state.status === "completed") {
|
||||
for (const item of content.state.content) {
|
||||
if (item.type !== "text") continue;
|
||||
try { const parsed: unknown = JSON.parse(item.text); if (parsed && typeof parsed === "object" && "answer" in parsed && typeof parsed.answer === "string") return parsed.answer; } catch { /* A non-JSON tool result is not a final answer. */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
if (message.time.completed && message.finish === "stop") {
|
||||
const text = message.content.filter((part) => part.type === "text").map((part) => part.text).join("\n");
|
||||
if (text) return text;
|
||||
}
|
||||
}
|
||||
throw new Error("Answer was consumed but no completed response could be verified; do not resend it");
|
||||
}
|
||||
|
||||
async revertMessage(sessionId: string, messageId: string) {
|
||||
const client = await this.ensureClient();
|
||||
return client.session.revert.stage({ sessionID: sessionId, messageID: messageId, files: false });
|
||||
@@ -488,7 +555,7 @@ export class OpencodeRuntimeAdapter {
|
||||
return client.session.interrupt({ sessionID: sessionId });
|
||||
}
|
||||
|
||||
async waitForSessionIdle(sessionId: string, timeoutMs = config.OPENCODE_TIMEOUT_MS) {
|
||||
async waitForSessionIdle(sessionId: string, timeoutMs = config.OPENCODE_TIMEOUT_MS, strict = false) {
|
||||
const client = await this.ensureClient();
|
||||
const startedAt = Date.now();
|
||||
|
||||
@@ -500,6 +567,7 @@ export class OpencodeRuntimeAdapter {
|
||||
await delay(100);
|
||||
}
|
||||
|
||||
if (strict) throw new Error("Native session did not reach the waiting boundary");
|
||||
logger.warn(
|
||||
{ sessionId, timeoutMs },
|
||||
"timed out waiting for opencode session to become idle",
|
||||
@@ -554,6 +622,16 @@ export class OpencodeRuntimeAdapter {
|
||||
return client.session.form.reply({ sessionID: options.sessionId, formID: options.requestId, answer: formAnswers(form, options.answers) });
|
||||
}
|
||||
|
||||
async getQuestionStatus(options: { requestId: string; sessionId: string }): Promise<"pending" | "answered" | "cancelled" | "missing"> {
|
||||
const client = await this.ensureClient();
|
||||
try { return (await client.session.form.get({ sessionID: options.sessionId, formID: options.requestId })).state.status; }
|
||||
catch (error) {
|
||||
const tag = error && typeof error === "object" && "_tag" in error ? error._tag : undefined;
|
||||
if (tag === "FormNotFoundError") return "missing";
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async rejectQuestion(options: { requestId: string; sessionId?: string }) {
|
||||
if (!options.sessionId) throw new Error("OpenCode 2 form cancellation requires sessionId");
|
||||
const client = await this.ensureClient();
|
||||
|
||||
@@ -14,6 +14,9 @@ export type RuntimeSessionContext = {
|
||||
taskId?: string;
|
||||
reportRevision?: number;
|
||||
workspaceDirectory?: string;
|
||||
hostTurnId?: string;
|
||||
hostMessageId?: string;
|
||||
hostQuestionClosed?: boolean;
|
||||
};
|
||||
|
||||
const contexts = new Map<string, RuntimeSessionContext>();
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { InteractionRequestStore } from "./sessions/interaction-request-store.js";
|
||||
import { registerChatInteractionBridge } from "./routes/chat-interaction-bridge.js";
|
||||
import { writeQueryMcpConnection } from "./runtime/query-mcp-connection.js";
|
||||
import { startQueryMcpServer } from "./mcp/query-server.js";
|
||||
import { createLocalQueryExecutor } from "./mcp/local-query-tools.js";
|
||||
@@ -47,6 +49,7 @@ const app = express();
|
||||
const sessionBridge = new ChatSessionBridge(opencodeRuntime);
|
||||
const sessionWorkspaces = new SessionWorkspaceStore(config.CONVERSATION_WORKSPACE_ROOT, join(config.PERSISTENCE_BACKUP_DIR, "legacy-chat"));
|
||||
const sessionMetadataStore = new SessionMetadataStore(undefined, sessionWorkspaces);
|
||||
const interactionRequestStore = new InteractionRequestStore(sessionWorkspaces);
|
||||
const sessionUiStateStore = new SessionUiStateStore(undefined, sessionWorkspaces);
|
||||
const memoryStore = new MemoryStore();
|
||||
const sessionTranscriptStore = new SessionTranscriptStore(undefined, sessionWorkspaces);
|
||||
@@ -68,6 +71,7 @@ registerSandboxToolBridge(app, { internalToken, runtime: opencodeRuntime });
|
||||
const skillStore = new SkillStore();
|
||||
registerLearningToolBridge(app, { internalToken, memoryStore, skillStore });
|
||||
registerTaskPublicationBridge(app, { internalToken });
|
||||
registerChatInteractionBridge(app, { internalToken, interactions: interactionRequestStore, runtime: opencodeRuntime });
|
||||
registerAgentMessageBridge(app, { internalToken, sessions: sessionMetadataStore, messages: agentMessages });
|
||||
const resolveTaskReader = registerTaskAgentBridge(app, {
|
||||
internalToken,
|
||||
@@ -221,6 +225,7 @@ app.get("/health", async (_req, res) => {
|
||||
app.use(
|
||||
"/api/v1",
|
||||
buildChatRouter({
|
||||
interactionRequestStore,
|
||||
agentMessages,
|
||||
frontendActionCoordinator,
|
||||
learningOrchestrator,
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { InteractionRequest } from "../chat/interaction-request-contract.js";
|
||||
import type { OpencodeRuntimeAdapter } from "../runtime/opencode.js";
|
||||
import type { SessionWorkspaceContext } from "../runtime/conversationWorkspace.js";
|
||||
import { InteractionRequestError, type InteractionRequestStore } from "./interaction-request-store.js";
|
||||
|
||||
export async function reconcileInteractionDelivery(
|
||||
context: SessionWorkspaceContext,
|
||||
record: InteractionRequest,
|
||||
store: InteractionRequestStore,
|
||||
runtime?: Pick<OpencodeRuntimeAdapter, "agentMessageState" | "readInteractionResult">,
|
||||
running = false
|
||||
): Promise<InteractionRequest> {
|
||||
if (running || !runtime || !record.delivery || !["answered", "skipped"].includes(record.status)
|
||||
|| ["delivered", "cancelled"].includes(record.delivery.status)) return record;
|
||||
try {
|
||||
if (await runtime.agentMessageState(context.sessionId, record.delivery.native_message_id) !== "delivered") return record;
|
||||
// Receipt recovery requires completed output, not merely a consumed input or partial text.
|
||||
if (!(await runtime.readInteractionResult(context.sessionId, record.delivery.native_message_id)).trim()) return record;
|
||||
} catch { return record; }
|
||||
try {
|
||||
return await store.updateDelivery(context, record.interaction_id, {
|
||||
expected_revision: record.delivery.revision, status: "delivered", response_id: record.delivery.response_id
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof InteractionRequestError && ["revision_conflict", "delivery_resolved"].includes(error.code)) {
|
||||
return await store.get(context, record.interaction_id) ?? record;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { z } from "zod";
|
||||
import { readJsonFile } from "../utils/fileStore.js";
|
||||
import type { SessionWorkspaceContext } from "../runtime/conversationWorkspace.js";
|
||||
import { SessionContextMismatchError } from "./metadataStore.js";
|
||||
import { SessionWorkspaceStore } from "./workspace-store.js";
|
||||
import { INTERACTION_VERSION, renderInteractionAnswer, interactionRequestSchema, interactionAnswersSchema, interactionBlocksTurn, interactionDraftInputSchema, interactionMutationInputSchema, interactionQuestionsSchema, interactionReplyInputSchema, type InteractionAnswer, type InteractionDelivery, type InteractionRequest, type InteractionResolvedAnswer } from "../chat/interaction-request-contract.js";
|
||||
|
||||
type Receipt = { operation: string; request_id: string; digest: string; result: InteractionRequest };
|
||||
type State = { version: typeof INTERACTION_VERSION; requests: InteractionRequest[]; receipts: Receipt[] };
|
||||
const filename = "interaction-requests.json";
|
||||
export class InteractionRequestError extends Error {
|
||||
constructor(readonly code: string, readonly status: number = 409, readonly current?: InteractionRequest) { super(code); }
|
||||
}
|
||||
const initial = (): State => ({ version: INTERACTION_VERSION, requests: [], receipts: [] });
|
||||
const parseState = (value: unknown): State => {
|
||||
if (value === undefined || value === null) return initial();
|
||||
return z.object({ version: z.literal(INTERACTION_VERSION), requests: z.array(interactionRequestSchema).max(1000), receipts: z.array(z.object({ operation: z.string(), request_id: z.string().min(1).max(128), digest: z.string(), result: interactionRequestSchema }).strict()) }).strict().parse(value);
|
||||
};
|
||||
const copy = <T>(value: T): T => structuredClone(value);
|
||||
const validateAnswers = (record: InteractionRequest, answers: InteractionAnswer[], complete: boolean): InteractionResolvedAnswer[] => {
|
||||
const parsed = interactionAnswersSchema.parse(answers);
|
||||
const result = parsed.map((answer) => {
|
||||
const q = record.questions.find((q) => q.question_id === answer.question_id);
|
||||
if (!q || (!q.allow_custom && answer.custom_text.length > 0) || (q.mode === "single" && answer.selected_option_ids.length > 1)) throw new InteractionRequestError("invalid_answer", 400);
|
||||
const selected_options = answer.selected_option_ids.map((id) => {
|
||||
const option = q.options.find((o) => o.id === id);
|
||||
if (!option) throw new InteractionRequestError("invalid_option", 400);
|
||||
return option;
|
||||
});
|
||||
return { ...answer, selected_options };
|
||||
});
|
||||
if (complete && record.questions.some((q) => {
|
||||
const a = result.find((a) => a.question_id === q.question_id);
|
||||
return !a || (q.required && !a.selected_option_ids.length && !a.custom_text.trim());
|
||||
})) throw new InteractionRequestError("incomplete_answer", 400);
|
||||
if (Buffer.byteLength(JSON.stringify(result)) > 16384 || result.reduce((n, a) => n + a.custom_text.length + a.selected_options.reduce((m, o) => m + o.label.length, 0), 0) > 10000) throw new InteractionRequestError("answer_too_large", 400);
|
||||
return result;
|
||||
};
|
||||
export class InteractionRequestStore {
|
||||
constructor(readonly workspaceStore: SessionWorkspaceStore) {}
|
||||
private async read(context: SessionWorkspaceContext) {
|
||||
if (!await this.workspaceStore.validate(context, filename)) throw new SessionContextMismatchError();
|
||||
return parseState(await readJsonFile(this.workspaceStore.path(context, filename)));
|
||||
}
|
||||
private async mutate<T>(context: SessionWorkspaceContext, operation: (state: State) => T): Promise<T> {
|
||||
return this.workspaceStore.updateRecord(context, filename, async ({ current }) => {
|
||||
const state = parseState(current);
|
||||
const result = operation(state);
|
||||
state.receipts = state.receipts.slice(-32);
|
||||
while (state.receipts.length && Buffer.byteLength(JSON.stringify(state.receipts)) > 128 * 1024) state.receipts.shift();
|
||||
if (state.requests.length > 1000 || Buffer.byteLength(JSON.stringify(state, null, 2)) > 2 * 1024 * 1024) throw new InteractionRequestError("interaction_capacity_exceeded", 413);
|
||||
return { result: copy(result), value: state };
|
||||
});
|
||||
}
|
||||
async get(context: SessionWorkspaceContext, id: string) { return copy((await this.read(context)).requests.find((r) => r.interaction_id === id) ?? null); }
|
||||
async forMessages(context: SessionWorkspaceContext, messageIds: ReadonlySet<string>) {
|
||||
if (!messageIds.size) return [];
|
||||
return copy((await this.read(context)).requests.filter((record) => record.delivery && messageIds.has(record.delivery.native_message_id)));
|
||||
}
|
||||
async list(context: SessionWorkspaceContext, options: { limit?: number; cursor?: string } = {}) {
|
||||
const limit = z.number().int().min(1).max(50).parse(options.limit ?? 20);
|
||||
const requests = [...(await this.read(context)).requests].reverse();
|
||||
const start = options.cursor ? requests.findIndex((r) => r.interaction_id === options.cursor) + 1 : 0;
|
||||
if (options.cursor && !start) throw new InteractionRequestError("invalid_cursor", 400);
|
||||
const items = requests.slice(start, start + limit);
|
||||
return copy({ items, next_cursor: start + limit < requests.length ? items.at(-1)?.interaction_id : undefined });
|
||||
}
|
||||
async getBlocking(context: SessionWorkspaceContext) { return copy((await this.read(context)).requests.find(interactionBlocksTurn) ?? null); }
|
||||
async getForTurn(context: SessionWorkspaceContext, turnId: string) { return copy((await this.read(context)).requests.find((record) => record.turn_id === turnId) ?? null); }
|
||||
async create(context: SessionWorkspaceContext, input: { message_id: string; turn_id: string; call_id: string; questions: unknown }) {
|
||||
const parsed = z.object({ message_id: z.string().min(1).max(128), turn_id: z.string().min(1).max(128), call_id: z.string().min(1).max(128), questions: interactionQuestionsSchema }).strict().parse(input);
|
||||
return this.mutate(context, (state) => {
|
||||
const existing = state.requests.find((r) => r.turn_id === parsed.turn_id && r.call_id === parsed.call_id);
|
||||
if (existing) {
|
||||
if (JSON.stringify(existing.questions) !== JSON.stringify(parsed.questions) || existing.message_id !== parsed.message_id) throw new InteractionRequestError("idempotency_mismatch");
|
||||
return existing;
|
||||
}
|
||||
if (state.requests.some(interactionBlocksTurn)) throw new InteractionRequestError("interaction_unresolved");
|
||||
const record: InteractionRequest = { version: INTERACTION_VERSION, interaction_id: randomUUID(), ...parsed, status: "pending", revision: 0, draft_revision: 0, draft: [], created_at: new Date().toISOString() };
|
||||
state.requests.push(record);
|
||||
return record;
|
||||
});
|
||||
}
|
||||
private async apply(context: SessionWorkspaceContext, id: string, operation: string, input: unknown, draft: boolean, change: (record: InteractionRequest, input: Record<string, unknown>) => void) {
|
||||
const parsed = (draft ? interactionDraftInputSchema : operation === "reply" ? interactionReplyInputSchema : interactionMutationInputSchema).parse(input);
|
||||
return this.mutate(context, (state) => {
|
||||
const digest = createHash("sha256").update(JSON.stringify(parsed)).digest("hex");
|
||||
const previous = state.receipts.find((r) => r.request_id === parsed.request_id);
|
||||
if (previous) {
|
||||
if (previous.operation !== `${id}:${operation}` || previous.digest !== digest) throw new InteractionRequestError("idempotency_mismatch");
|
||||
return state.requests.find((r) => r.interaction_id === id) ?? previous.result;
|
||||
}
|
||||
const record = state.requests.find((r) => r.interaction_id === id);
|
||||
if (!record) throw new InteractionRequestError("interaction_not_found", 404);
|
||||
if (record.status !== "pending") throw new InteractionRequestError("interaction_resolved", 409, copy(record));
|
||||
const expected = "expected_draft_revision" in parsed ? parsed.expected_draft_revision : parsed.expected_revision;
|
||||
if (expected !== (draft ? record.draft_revision : record.revision)) throw new InteractionRequestError("revision_conflict", 409, copy(record));
|
||||
change(record, parsed);
|
||||
if (draft) record.draft_revision++; else { record.revision++; record.resolved_at = new Date().toISOString(); }
|
||||
state.receipts.push({ operation: `${id}:${operation}`, request_id: parsed.request_id, digest, result: copy(record) });
|
||||
return record;
|
||||
});
|
||||
}
|
||||
updateDraft(context: SessionWorkspaceContext, id: string, input: unknown) { return this.apply(context, id, "draft", input, true, (r, p) => { validateAnswers(r, p.answers as InteractionAnswer[], false); r.draft = p.answers as InteractionAnswer[]; }); }
|
||||
reply(context: SessionWorkspaceContext, id: string, input: unknown) { return this.apply(context, id, "reply", input, false, (r, p) => { r.answers = validateAnswers(r, p.answers as InteractionAnswer[], true); r.status = "answered";
|
||||
try { renderInteractionAnswer(r); } catch { throw new InteractionRequestError("answer_too_large", 400); }
|
||||
r.delivery = this.delivery(); }); }
|
||||
skip(context: SessionWorkspaceContext, id: string, input: unknown) { return this.apply(context, id, "skip", input, false, (r) => { if (r.questions.some((q) => q.required)) throw new InteractionRequestError("required_question", 400); r.status = "skipped"; r.answers = []; r.delivery = this.delivery(); }); }
|
||||
cancel(context: SessionWorkspaceContext, id: string, input: unknown) { return this.apply(context, id, "cancel", input, false, (r) => { r.status = "cancelled"; }); }
|
||||
private delivery(): InteractionDelivery { return { native_message_id: `msg_${randomUUID().replaceAll("-", "")}`, revision: 0, status: "queued" }; }
|
||||
async updateDelivery(context: SessionWorkspaceContext, id: string, input: { expected_revision: number; status: InteractionDelivery["status"]; response_id?: string; error_code?: string }) {
|
||||
const parsed = z.object({ expected_revision: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER), status: z.enum(["queued", "dispatching", "delivered", "blocked", "cancelled"]), response_id: z.string().min(1).max(128).optional(), error_code: z.string().min(1).max(128).optional() }).strict().parse(input);
|
||||
return this.mutate(context, (state) => {
|
||||
const r = state.requests.find((r) => r.interaction_id === id);
|
||||
if (!r?.delivery) throw new InteractionRequestError("delivery_not_found", 404);
|
||||
if (r.delivery.revision !== parsed.expected_revision) throw new InteractionRequestError("revision_conflict", 409, copy(r));
|
||||
if (["delivered", "cancelled"].includes(r.delivery.status)) throw new InteractionRequestError("delivery_resolved");
|
||||
r.delivery = { native_message_id: r.delivery.native_message_id, revision: r.delivery.revision + 1, status: parsed.status, ...(parsed.response_id ? { response_id: parsed.response_id } : {}), ...(parsed.error_code ? { error_code: parsed.error_code } : {}) };
|
||||
return r;
|
||||
});
|
||||
}
|
||||
async resume(context: SessionWorkspaceContext, id: string, input: unknown) {
|
||||
const parsed = interactionMutationInputSchema.parse(input);
|
||||
return this.mutate(context, (state) => {
|
||||
const digest = createHash("sha256").update(JSON.stringify(parsed)).digest("hex");
|
||||
const previous = state.receipts.find((r) => r.request_id === parsed.request_id);
|
||||
if (previous) {
|
||||
if (previous.operation !== `${id}:resume` || previous.digest !== digest) throw new InteractionRequestError("idempotency_mismatch");
|
||||
return state.requests.find((r) => r.interaction_id === id) ?? previous.result;
|
||||
}
|
||||
const record = state.requests.find((r) => r.interaction_id === id);
|
||||
if (!record?.delivery) throw new InteractionRequestError("delivery_not_found", 404);
|
||||
if (record.delivery.revision !== parsed.expected_revision) throw new InteractionRequestError("revision_conflict", 409, copy(record));
|
||||
state.receipts.push({ operation: `${id}:resume`, request_id: parsed.request_id, digest, result: copy(record) });
|
||||
return record;
|
||||
});
|
||||
}
|
||||
claimDelivery(context: SessionWorkspaceContext, id: string, input: { expected_revision: number; response_id: string }) {
|
||||
return this.updateDelivery(context, id, { ...input, status: "dispatching" });
|
||||
}
|
||||
async invalidate(context: SessionWorkspaceContext, id: string, input: unknown) {
|
||||
return this.apply(context, id, "invalidate", input, false, (r) => { r.status = "invalidated"; });
|
||||
}
|
||||
async cancelPending(context: SessionWorkspaceContext) {
|
||||
return this.mutate(context, (state) => {
|
||||
for (const r of state.requests) {
|
||||
if (r.status === "pending") { r.status = "cancelled"; r.revision++; r.resolved_at = new Date().toISOString(); }
|
||||
if (r.delivery && ["queued", "blocked"].includes(r.delivery.status)) { r.delivery.status = "cancelled"; r.delivery.revision++; }
|
||||
}
|
||||
return state.requests;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,19 @@ export class SessionUiStateStore {
|
||||
return state;
|
||||
}
|
||||
|
||||
async update(context: SessionUiStateContext, change: (state: SessionUiStateRecord) => SessionUiStateRecord) {
|
||||
if (this.workspaces) return this.workspaces.updateRecord(this.workspaceContext(context), "messages.json", async ({ current }) => {
|
||||
const state = current as SessionUiStateRecord | null;
|
||||
if (!state || state.sessionId !== context.sessionId || !Array.isArray(state.messages)) throw new Error("chat state unavailable");
|
||||
const next = change(state);
|
||||
if (next.sessionId !== context.sessionId) throw new Error("chat state session mismatch");
|
||||
return { result: next, value: next };
|
||||
});
|
||||
const state = await this.read(context);
|
||||
if (!state) throw new Error("chat state unavailable");
|
||||
return this.write(context, change(state));
|
||||
}
|
||||
|
||||
async remove(context: SessionUiStateContext) {
|
||||
if (this.workspaces && !await this.workspaces.validate(this.workspaceContext(context), "messages.json")) return;
|
||||
await removeFileIfExists(this.filePath(context));
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
import { atomicWriteJson, listJsonFiles, readJsonFile } from "../utils/fileStore.js";
|
||||
import { SessionContextMismatchError, type SessionRecord } from "./metadataStore.js";
|
||||
|
||||
type ChatRecordName = "messages.json" | "transcript.json" | "learning.json";
|
||||
type ChatRecordName = "messages.json" | "transcript.json" | "learning.json" | "interaction-requests.json";
|
||||
type LegacySources = { metadataDir: string; uiStateDir: string; transcriptDir: string; learningStateDir?: string };
|
||||
const exists = async (path: string) => {
|
||||
try { await lstat(path); return true; }
|
||||
@@ -104,6 +104,23 @@ export class SessionWorkspaceStore {
|
||||
});
|
||||
}
|
||||
|
||||
/** Read, compare and replace under the same queue as message writes and deletion. */
|
||||
async updateRecord<T>(
|
||||
context: SessionWorkspaceContext,
|
||||
name: ChatRecordName,
|
||||
operation: (records: { messages: unknown; current: unknown }) => Promise<{ result: T; value?: object }>,
|
||||
): Promise<T> {
|
||||
return this.serialize(context, async () => {
|
||||
if (!await this.validate(context, "messages.json") || !await this.validate(context, name)) throw new SessionContextMismatchError();
|
||||
const messages = await readJsonFile<unknown>(this.path(context, "messages.json"));
|
||||
const current = await readJsonFile<unknown>(this.path(context, name));
|
||||
const update = await operation({ messages, current });
|
||||
if (!await this.validate(context, "messages.json") || !await this.validate(context, name)) throw new SessionContextMismatchError();
|
||||
if (update.value !== undefined) await atomicWriteJson(this.path(context, name), update.value as Record<string, unknown>, 0o600);
|
||||
return update.result;
|
||||
});
|
||||
}
|
||||
|
||||
async markDeleted(context: SessionWorkspaceContext) {
|
||||
return this.serialize(context, async () => {
|
||||
const directory = await this.ensure(context, true);
|
||||
|
||||
Reference in New Issue
Block a user