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
@@ -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] };
});
}
+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;
};
+29
View File
@@ -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[]>;
+1
View File
@@ -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(),
+2 -1
View File
@@ -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;