summaryrefslogtreecommitdiffhomepage
path: root/src/features/chat/delivery.test.ts
blob: 99ad2d407e1f06bf7f546cb44b796d65a5d5fb93 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
import { describe, expect, it, vi } from "vitest";
import { createPendingInputStorage } from "./adapter/pending-inputs";
import { createChatStore } from "./store.svelte";
import {
  createFakeCache,
  createFakeHistorySync,
  createFakeMetricsSync,
  createFakeTransport,
} from "./test-helpers";

function storage(): Storage {
  const values = new Map<string, string>();
  return {
    get length() {
      return values.size;
    },
    getItem: (key) => values.get(key) ?? null,
    setItem: (key, value) => {
      values.set(key, value);
    },
    removeItem: (key) => {
      values.delete(key);
    },
    clear: () => values.clear(),
    key: (index) => [...values.keys()][index] ?? null,
  };
}

function fixture(saved = storage(), metricsSync = createFakeMetricsSync().impl) {
  const transport = createFakeTransport();
  let id = 0;
  const store = createChatStore({
    conversationId: "c",
    createRequestId: () => `request-${++id}`,
    pendingInputStorage: createPendingInputStorage(saved, "c"),
    transport: transport.impl,
    historySync: createFakeHistorySync().impl,
    metricsSync,
    cache: createFakeCache().impl,
  });
  return { store, transport, saved };
}

const images = [{ url: "data:image/png;base64,AAAA", mimeType: "image/png" }];

describe("input delivery", () => {
  it.each([
    "send",
    "queueMessage",
  ] as const)("saves %s input through refresh without automatically retransmitting", (method) => {
    const f = fixture();
    f.store[method]("keep me", images);
    expect(f.store.pendingInputs).toHaveLength(1);
    f.store.dispose();
    const reloaded = fixture(f.saved);
    expect(reloaded.store.pendingInputs).toMatchObject([{ request: { images } }]);
    expect(JSON.stringify(reloaded.store.pendingInputs)).toContain("keep me");
    expect(reloaded.transport.sent).toEqual([]);
    expect(reloaded.transport.sentQueue).toEqual([]);
    reloaded.store.dispose();
  });

  it("streamed user echoes and wrong receipts cannot discard an unconfirmed send", () => {
    const f = fixture();
    f.store.send("mine", images);
    f.store.handleDelta({
      type: "chat.delta",
      event: { type: "user-message", conversationId: "c", turnId: "t", text: "mine" },
    });
    f.store.handleDelta({ type: "chat.accepted", conversationId: "other", requestId: "request-1" });
    f.store.handleDelta({ type: "chat.accepted", conversationId: "c", requestId: "other" });
    expect(f.store.pendingInputs).toHaveLength(1);
    f.store.handleDelta({ type: "chat.accepted", conversationId: "c", requestId: "request-1" });
    expect(f.store.pendingInputs).toEqual([]);
    expect(createPendingInputStorage(f.saved, "c").load()).toEqual([]);
    f.store.dispose();
  });

  it("retains explicitly rejected input and images for recovery until discarded", () => {
    const f = fixture();
    f.store.send("mine", images);
    f.store.handleDelta({
      type: "chat.error",
      conversationId: "c",
      requestId: "request-1",
      message: "disk full",
    });
    const reloaded = fixture(f.saved);
    expect(reloaded.store.pendingInputs).toMatchObject([
      { rejection: "disk full", request: { message: "mine", images } },
    ]);
    reloaded.store.discardPendingInput("request-1");
    expect(createPendingInputStorage(f.saved, "c").load()).toEqual([]);
    f.store.dispose();
    reloaded.store.dispose();
  });

  it.each([
    "send",
    "queueMessage",
  ] as const)("does not clear or transmit %s input if saving fails", (method) => {
    const saved = storage();
    saved.setItem = () => {
      throw new Error("quota exceeded");
    };
    const f = fixture(saved);
    expect(() => f.store[method]("mine", images)).toThrow("quota exceeded");
    expect(f.store.pendingInputs).toEqual([]);
    expect(f.store.chunks).toEqual([]);
    expect(f.transport.sent).toEqual([]);
    expect(f.transport.sentQueue).toEqual([]);
    f.store.dispose();
  });

  it("coalesces a reconnect burst and permits one metrics catch-up after completion", async () => {
    let resolve!: (value: { turns: [] }) => void;
    const first = {
      promise: new Promise<{ turns: [] }>((done) => {
        resolve = done;
      }),
      resolve: (value: { turns: [] }) => resolve(value),
    };
    const metricsSync = vi.fn().mockReturnValueOnce(first.promise).mockResolvedValue({ turns: [] });
    const f = fixture(storage(), metricsSync);
    for (let i = 0; i < 100; i++) f.store.resync();
    expect(metricsSync).toHaveBeenCalledTimes(1);
    first.resolve({ turns: [] });
    await vi.waitFor(() => expect(metricsSync).toHaveBeenCalledTimes(2));
    f.store.dispose();
  });
});

it("concurrent browser tabs cannot overwrite each other's saved messages or receipts", () => {
  const saved = storage();
  const first = createPendingInputStorage(saved, "c");
  const second = createPendingInputStorage(saved, "c");
  first.load();
  second.load();
  const input = (requestId: string) => ({
    request: { type: "chat.send" as const, conversationId: "c", requestId, message: requestId },
  });
  first.save([input("first")]);
  second.save([input("second")]);
  first.save([]);
  expect(createPendingInputStorage(saved, "c").load()).toEqual([input("second")]);
});