fix: unify chat process and reply presentation
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { expect, it, vi } from "vitest";
|
||||
import type { InteractionRequest, InteractionRequestApi } from "../api/interaction-requests";
|
||||
import { useInteractionRequests } from "./use-interaction-requests";
|
||||
const record = (status: InteractionRequest["status"] = "pending", revision = 0): InteractionRequest => ({ version: "agent-interaction-request@1", interaction_id: "i", message_id: "m", turn_id: "t", call_id: "c", questions: [{ question_id: "q", title: "确认范围", body: "选择范围", mode: "single", options: [{ id: "all", label: "全部" }], required: true, allow_custom: true }], status, revision, draft_revision: 0, draft: [], created_at: "now" });
|
||||
it("debounces draft CAS and never continues on open; late pending reads cannot revive accepted answers", async () => {
|
||||
vi.useFakeTimers();
|
||||
const root = createRoot(document.createElement("div"));
|
||||
let readResolve!: (value: { items: InteractionRequest[] }) => void;
|
||||
const api: InteractionRequestApi = { listInteractionRequests: vi.fn(async () => ({ items: [record()] })), getInteractionRequest: vi.fn(async () => record("answered", 1)), writeInteractionRequest: vi.fn(async (_session, _id, action) => action === "draft" ? { ...record(), draft_revision: 1 } : { ...record("answered", 1), delivery: { native_message_id: "native", revision: 0, status: "queued" as const } }) };
|
||||
const resume = vi.fn(async () => {});
|
||||
let current!: ReturnType<typeof useInteractionRequests>;
|
||||
function Probe({ session = "first" }: { session?: string }) { current = useInteractionRequests(api, "actor:project", session, resume); return null; }
|
||||
try {
|
||||
await act(async () => root.render(<Probe />));
|
||||
expect(resume).not.toHaveBeenCalled();
|
||||
await act(async () => current.update("i", [{ question_id: "q", selected_option_ids: ["all"], custom_text: "" }]));
|
||||
await act(async () => vi.advanceTimersByTimeAsync(499));
|
||||
expect(api.writeInteractionRequest).not.toHaveBeenCalled();
|
||||
await act(async () => vi.advanceTimersByTimeAsync(1));
|
||||
expect(api.writeInteractionRequest).toHaveBeenCalledWith("first", "i", "draft", expect.objectContaining({ expected_draft_revision: 0 }));
|
||||
vi.mocked(api.listInteractionRequests).mockImplementationOnce(() => new Promise((resolve) => { readResolve = resolve; }));
|
||||
let refresh!: Promise<void>;
|
||||
await act(async () => { refresh = current.refresh(); });
|
||||
// Finish checking then submit. A stale read dispatched before submit must not win afterward.
|
||||
await act(async () => { readResolve({ items: [record()] }); await refresh; });
|
||||
await act(async () => current.act("i", "reply"));
|
||||
expect(current.entries[0].record.status).toBe("answered");
|
||||
expect(resume).toHaveBeenCalledWith("first", "i");
|
||||
await act(async () => current.refresh());
|
||||
expect(current.entries[0].record.status).toBe("answered");
|
||||
await act(async () => root.render(<Probe session="second" />));
|
||||
expect(current.entries[0].record.status).toBe("pending");
|
||||
await act(async () => root.render(<Probe />));
|
||||
expect(current.entries[0].record.status).toBe("answered");
|
||||
} finally { await act(async () => root.unmount()); vi.useRealTimers(); }
|
||||
});
|
||||
it("binds a delayed submission to its original session and keeps the next session unchanged", async () => {
|
||||
const root = createRoot(document.createElement("div"));
|
||||
let resolveReply!: (value: InteractionRequest) => void;
|
||||
const api: InteractionRequestApi = { listInteractionRequests: vi.fn(async () => ({ items: [record()] })), getInteractionRequest: vi.fn(async () => record()), writeInteractionRequest: vi.fn(() => new Promise<InteractionRequest>((resolve) => { resolveReply = resolve; })) };
|
||||
const resume = vi.fn(async () => {});
|
||||
let current!: ReturnType<typeof useInteractionRequests>;
|
||||
function Probe({ session }: { session: string }) { current = useInteractionRequests(api, "actor", session, resume); return null; }
|
||||
try {
|
||||
await act(async () => root.render(<Probe session="first" />));
|
||||
await act(async () => current.update("i", [{ question_id: "q", selected_option_ids: ["all"], custom_text: "first draft" }]));
|
||||
let submission!: Promise<void>;
|
||||
await act(async () => { submission = current.act("i", "reply"); });
|
||||
await act(async () => root.render(<Probe session="second" />));
|
||||
await act(async () => { resolveReply({ ...record("answered", 1), delivery: { native_message_id: "native", revision: 0, status: "queued" } }); await submission; });
|
||||
expect(resume).toHaveBeenCalledWith("first", "i");
|
||||
expect(current.entries[0].record.status).toBe("pending");
|
||||
expect(current.entries[0].draft).toEqual([]);
|
||||
await act(async () => root.render(<Probe session="first" />));
|
||||
expect(current.entries[0].record.status).toBe("answered");
|
||||
} finally { await act(async () => root.unmount()); }
|
||||
});
|
||||
it("keeps delivery revisions monotonic and ignores superseded reads", async () => {
|
||||
const root = createRoot(document.createElement("div"));
|
||||
const delivered = { ...record("answered", 1), delivery: { native_message_id: "native", revision: 2, status: "delivered" as const } };
|
||||
const api: InteractionRequestApi = { listInteractionRequests: vi.fn(async () => ({ items: [delivered] })), getInteractionRequest: vi.fn(async () => delivered), writeInteractionRequest: vi.fn(async () => delivered) };
|
||||
const resume = vi.fn(async () => {});
|
||||
let current!: ReturnType<typeof useInteractionRequests>;
|
||||
function Probe() { current = useInteractionRequests(api, "actor", "session", resume); return null; }
|
||||
try {
|
||||
await act(async () => root.render(<Probe />));
|
||||
vi.mocked(api.listInteractionRequests).mockResolvedValueOnce({ items: [{ ...delivered, delivery: { ...delivered.delivery, revision: 1, status: "queued" } }] });
|
||||
await act(async () => current.refresh());
|
||||
expect(current.entries[0].record.delivery?.status).toBe("delivered");
|
||||
let first!: (value: { items: InteractionRequest[] }) => void;
|
||||
let second!: (value: { items: InteractionRequest[] }) => void;
|
||||
vi.mocked(api.listInteractionRequests).mockImplementationOnce(() => new Promise((resolve) => { first = resolve; })).mockImplementationOnce(() => new Promise((resolve) => { second = resolve; }));
|
||||
let a!: Promise<void>, b!: Promise<void>;
|
||||
await act(async () => { a = current.refresh(); b = current.refresh(); });
|
||||
await act(async () => { first({ items: [delivered] }); await a; });
|
||||
expect(current.checking).toBe(true);
|
||||
await act(async () => { second({ items: [delivered] }); await b; });
|
||||
expect(current.checking).toBe(false);
|
||||
} finally { await act(async () => root.unmount()); }
|
||||
});
|
||||
it("purges revoked snapshots and prevents pending reads and old callbacks from restoring them", async () => {
|
||||
const { InteractionRequestApiError } = await import("../api/interaction-requests");
|
||||
const root = createRoot(document.createElement("div"));
|
||||
const api: InteractionRequestApi = { listInteractionRequests: vi.fn(async () => ({ items: [record()] })), getInteractionRequest: vi.fn(async () => record()), writeInteractionRequest: vi.fn(async () => record()) };
|
||||
const resume = vi.fn(async () => {});
|
||||
let current!: ReturnType<typeof useInteractionRequests>;
|
||||
function Probe({ client = api }: { client?: InteractionRequestApi }) { current = useInteractionRequests(client, "actor", "session", resume); return null; }
|
||||
try {
|
||||
await act(async () => root.render(<Probe />));
|
||||
const staleAction = current.act;
|
||||
vi.mocked(api.listInteractionRequests).mockRejectedValueOnce(new InteractionRequestApiError("无权访问", 403));
|
||||
await act(async () => current.refresh());
|
||||
expect(current.entries).toEqual([]);
|
||||
await act(async () => staleAction("i", "reply"));
|
||||
expect(api.writeInteractionRequest).not.toHaveBeenCalled();
|
||||
const next = { ...api, listInteractionRequests: vi.fn(async () => ({ items: [] })) };
|
||||
await act(async () => root.render(<Probe client={next} />));
|
||||
expect(current.entries).toEqual([]);
|
||||
await act(async () => staleAction("i", "reply"));
|
||||
expect(resume).not.toHaveBeenCalled();
|
||||
} finally { await act(async () => root.unmount()); }
|
||||
});
|
||||
it("flushes the old session draft on switch without waiting for the debounce", async () => {
|
||||
vi.useFakeTimers();
|
||||
const root = createRoot(document.createElement("div"));
|
||||
const api: InteractionRequestApi = { listInteractionRequests: vi.fn(async () => ({ items: [record()] })), getInteractionRequest: vi.fn(async () => record()), writeInteractionRequest: vi.fn(async () => ({ ...record(), draft_revision: 1 })) };
|
||||
const resume = vi.fn(async () => {});
|
||||
let current!: ReturnType<typeof useInteractionRequests>;
|
||||
function Probe({ session }: { session: string }) { current = useInteractionRequests(api, "actor", session, resume); return null; }
|
||||
try {
|
||||
await act(async () => root.render(<Probe session="first" />));
|
||||
await act(async () => current.update("i", [{ question_id: "q", selected_option_ids: [], custom_text: "保留的输入" }]));
|
||||
expect(api.writeInteractionRequest).not.toHaveBeenCalled();
|
||||
await act(async () => root.render(<Probe session="second" />));
|
||||
expect(api.writeInteractionRequest).toHaveBeenCalledWith("first", "i", "draft", expect.objectContaining({ answers: [{ question_id: "q", selected_option_ids: [], custom_text: "保留的输入" }] }));
|
||||
expect(current.entries[0].draft).toEqual([]);
|
||||
} finally { await act(async () => root.unmount()); vi.useRealTimers(); }
|
||||
});
|
||||
it("drops an in-flight accepted reply after the API identity changes", async () => {
|
||||
const root = createRoot(document.createElement("div"));
|
||||
let resolveReply!: (value: InteractionRequest) => void;
|
||||
const api: InteractionRequestApi = { listInteractionRequests: vi.fn(async () => ({ items: [record()] })), getInteractionRequest: vi.fn(async () => record()), writeInteractionRequest: vi.fn(() => new Promise<InteractionRequest>((resolve) => { resolveReply = resolve; })) };
|
||||
const other = { ...api, listInteractionRequests: vi.fn(async () => ({ items: [] })) };
|
||||
const resume = vi.fn(async () => {});
|
||||
let current!: ReturnType<typeof useInteractionRequests>;
|
||||
function Probe({ client }: { client: InteractionRequestApi }) { current = useInteractionRequests(client, "project", "session", resume); return null; }
|
||||
try {
|
||||
await act(async () => root.render(<Probe client={api} />));
|
||||
let submission!: Promise<void>;
|
||||
await act(async () => { submission = current.act("i", "reply"); });
|
||||
await act(async () => root.render(<Probe client={other} />));
|
||||
await act(async () => { resolveReply({ ...record("answered", 1), delivery: { native_message_id: "native", revision: 0, status: "queued" } }); await submission; });
|
||||
expect(current.entries).toEqual([]);
|
||||
expect(resume).not.toHaveBeenCalled();
|
||||
} finally { await act(async () => root.unmount()); }
|
||||
});
|
||||
Reference in New Issue
Block a user