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; function Probe({ session = "first" }: { session?: string }) { current = useInteractionRequests(api, "actor:project", session, resume); return null; } try { await act(async () => root.render()); 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; 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()); expect(current.entries[0].record.status).toBe("pending"); await act(async () => root.render()); 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((resolve) => { resolveReply = resolve; })) }; const resume = vi.fn(async () => {}); let current!: ReturnType; function Probe({ session }: { session: string }) { current = useInteractionRequests(api, "actor", session, resume); return null; } try { await act(async () => root.render()); await act(async () => current.update("i", [{ question_id: "q", selected_option_ids: ["all"], custom_text: "first draft" }])); let submission!: Promise; await act(async () => { submission = current.act("i", "reply"); }); await act(async () => root.render()); 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()); 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; function Probe() { current = useInteractionRequests(api, "actor", "session", resume); return null; } try { await act(async () => root.render()); 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, b!: Promise; 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; function Probe({ client = api }: { client?: InteractionRequestApi }) { current = useInteractionRequests(client, "actor", "session", resume); return null; } try { await act(async () => root.render()); 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()); 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; function Probe({ session }: { session: string }) { current = useInteractionRequests(api, "actor", session, resume); return null; } try { await act(async () => root.render()); await act(async () => current.update("i", [{ question_id: "q", selected_option_ids: [], custom_text: "保留的输入" }])); expect(api.writeInteractionRequest).not.toHaveBeenCalled(); await act(async () => root.render()); 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((resolve) => { resolveReply = resolve; })) }; const other = { ...api, listInteractionRequests: vi.fn(async () => ({ items: [] })) }; const resume = vi.fn(async () => {}); let current!: ReturnType; function Probe({ client }: { client: InteractionRequestApi }) { current = useInteractionRequests(client, "project", "session", resume); return null; } try { await act(async () => root.render()); let submission!: Promise; await act(async () => { submission = current.act("i", "reply"); }); await act(async () => root.render()); 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()); } });