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;
|
||||
|
||||
Reference in New Issue
Block a user