fix: preserve interaction replies across chat recovery

This commit is contained in:
2026-10-29 17:53:33 +08:00
parent fbe273e90a
commit 52681a8451
38 changed files with 1623 additions and 314 deletions
+68
View File
@@ -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;
};