1
31import { test, expect } from "../fixtures";
32import {
33 armAiMockFailures,
34 createPage,
35 setAiBudgetLimit,
36 seamServiceToken
37} from "../utils/api";
38import type { APIRequestContext } from "@playwright/test";
39
40
41
42
43const QUESTION = "Summarize the content on this page";
44
45const ECHO_PREFIX = QUESTION.slice(0, 40);
46
47interface ConversationRow {
48 id: string;
49 title: string | null;
50}
51
52async function listConversations(api: APIRequestContext, wsId: string): Promise<ConversationRow[]> {
53 const res = await api.get(`/api/v1/workspaces/${wsId}/ai/conversations`);
54 expect(res.status(), `listConversations(${wsId}): ${res.status()} ${await res.text()}`).toBe(200);
55 const body = (await res.json()) as { data: ConversationRow[] };
56 return body.data;
57}
58
59
60async function getConversation(api: APIRequestContext, id: string): Promise<ConversationRow> {
61 const res = await api.get(`/api/v1/ai/conversations/${id}`);
62 expect(res.status(), `getConversation(${id}): ${res.status()} ${await res.text()}`).toBe(200);
63 const body = (await res.json()) as { data: ConversationRow };
64 return body.data;
65}
66
67async function getConversationStatus(api: APIRequestContext, id: string): Promise<number> {
68 return (await api.get(`/api/v1/ai/conversations/${id}`)).status();
69}
70
71test.describe("Ask AI (TASK-0122)", () => {
72 test("page toolbar → panel + context badge → streams ≥3 updates → persists across reload", async ({
73 authPage,
74 api,
75 freshWorkspace
76 }) => {
77 const ws = await freshWorkspace("AI Chat Space");
78 const page = await createPage(api, ws.id, { title: `AI Probe ${Date.now()}` });
79
80 await authPage.goto(`/w/${ws.id}/page/${page.id}`);
81 await authPage.getByTestId("ask-ai-button").click();
82
83
84 const panel = authPage.getByTestId("ai-panel");
85 await expect(panel).toBeVisible();
86 await expect(authPage.getByTestId("ai-context-badge")).toHaveText(page.title);
87
88 const input = authPage.getByTestId("ai-input");
89 await expect(input).toBeVisible();
90
91
92 await input.fill(QUESTION);
93 await authPage.getByTestId("ai-send").click();
94
95 const live = authPage.getByTestId("ai-message-live");
96 const seen = new Set<string>();
97 await expect
98 .poll(
99 async () => {
100 if ((await live.count()) > 0) {
101 const t = (await live.first().textContent())?.trim() ?? "";
102
103 if (t && !/thinking/i.test(t)) seen.add(t);
104 }
105 return seen.size;
106 },
107 { timeout: 20_000, intervals: [25] }
108 )
109 .toBeGreaterThanOrEqual(3);
110
111
112 const assistant = authPage.getByTestId("ai-message-assistant").last();
113 await expect(assistant).toBeVisible({ timeout: 20_000 });
114 await expect(assistant).toContainText(ECHO_PREFIX);
115 await expect(authPage.getByTestId("ai-message-user")).toContainText(QUESTION);
116
117
118
119 await authPage.reload();
120 await authPage.getByTestId("ask-ai-button").click();
121 await expect(panel).toBeVisible();
122
123
124 const item = authPage.getByTestId("ai-history-item");
125 await expect(item).toBeVisible({ timeout: 15_000 });
126 await expect(item).toContainText(QUESTION.slice(0, 60));
127
128
129 await item.click();
130 await expect(authPage.getByTestId("ai-message-user")).toContainText(QUESTION);
131 await expect(authPage.getByTestId("ai-message-assistant").last()).toContainText(ECHO_PREFIX);
132 });
133
134 test("command palette Ask AI → create / rename / delete round-trips", async ({
135 authPage,
136 api,
137 freshWorkspace
138 }) => {
139 const ws = await freshWorkspace("AI Palette Space");
140 const page = await createPage(api, ws.id, { title: "Palette Probe" });
141 await authPage.goto(`/w/${ws.id}/page/${page.id}`);
142
143
144 await authPage.getByTestId("command-palette-button").click();
145 const palette = authPage.getByRole("dialog", { name: "Command palette" });
146 await expect(palette).toBeVisible();
147 const paletteInput = palette.getByRole("combobox");
148 await expect(paletteInput).toBeVisible();
149 await paletteInput.fill("ask ai");
150 await paletteInput.press("Enter");
151
152
153 const panel = authPage.getByTestId("ai-panel");
154 await expect(panel).toBeVisible();
155 await expect(authPage.getByTestId("ai-context-badge")).toHaveCount(0);
156
157
158 const input = authPage.getByTestId("ai-input");
159 await expect(input).toBeVisible();
160 await input.fill("What is this workspace about");
161 await authPage.getByTestId("ai-send").click();
162 await expect(authPage.getByTestId("ai-message-assistant").last()).toBeVisible({ timeout: 20_000 });
163
164
165 const item = authPage.getByTestId("ai-history-item");
166 await expect(item).toBeVisible();
167 await expect(item).toContainText("What is this workspace about");
168
169 const [conv] = await listConversations(api, ws.id);
170 expect(conv, "one conversation after the first send").toBeTruthy();
171 const convId = conv!.id;
172
173
174
175 await item.dblclick();
176 const rename = authPage.getByTestId("ai-history-rename");
177 await expect(rename).toBeVisible();
178 await rename.fill("Renamed Chat");
179 await rename.press("Enter");
180 await expect(authPage.getByTestId("ai-history-item")).toContainText("Renamed Chat");
181 await expect
182 .poll(async () => (await getConversation(api, convId)).title, { timeout: 10_000 })
183 .toBe("Renamed Chat");
184
185
186 await authPage.getByTestId("ai-history-delete").click();
187 await authPage.getByTestId("ai-delete-confirm-yes").click();
188 await expect(authPage.getByTestId("ai-delete-confirm")).toHaveCount(0);
189 await expect(authPage.getByTestId("ai-history-item")).toHaveCount(0);
190 await expect
191 .poll(async () => getConversationStatus(api, convId), { timeout: 10_000 })
192 .toBe(404);
193 });
194
195 test("provider outage → retryable banner + Retry re-sends without duplicating", async ({
196 authPage,
197 api,
198 freshWorkspace
199 }) => {
200 const token = seamServiceToken();
201 const ws = await freshWorkspace("AI Outage Space");
202 const page = await createPage(api, ws.id, { title: "Outage Probe" });
203 await authPage.goto(`/w/${ws.id}/page/${page.id}`);
204 await authPage.getByTestId("ask-ai-button").click();
205 const panel = authPage.getByTestId("ai-panel");
206 await expect(panel).toBeVisible();
207 const input = authPage.getByTestId("ai-input");
208 await expect(input).toBeVisible();
209
210
211
212
213
214
215 await armAiMockFailures(token, ws.id, 500, 2);
216
217 await input.fill(QUESTION);
218 await authPage.getByTestId("ai-send").click();
219
220
221 const banner = authPage.getByTestId("ai-error-banner");
222 await expect(banner).toBeVisible({ timeout: 20_000 });
223 await expect(banner).toContainText("Something went wrong");
224 await expect(authPage.getByTestId("ai-retry")).toBeVisible();
225
226 const [conv] = await listConversations(api, ws.id);
227 expect(conv?.id, "the failed turn created a conversation + user row").toBeTruthy();
228 const convId = conv!.id;
229
230
231 await authPage.getByTestId("ai-retry").click();
232 await expect(authPage.getByTestId("ai-message-assistant").last()).toContainText(ECHO_PREFIX, {
233 timeout: 20_000
234 });
235 await expect(banner).toHaveCount(0);
236
237
238
239 const res = await api.get(`/api/v1/ai/conversations/${convId}/messages?limit=50`);
240 expect(res.status()).toBe(200);
241 const body = (await res.json()) as { data: Array<{ role: string }> };
242 expect(body.data.filter((m) => m.role === "user")).toHaveLength(1);
243 });
244
245 test("budget exhaustion → pre-stream 402 banner, no Retry", async ({
246 authPage,
247 api,
248 freshWorkspace
249 }) => {
250 const token = seamServiceToken();
251 const ws = await freshWorkspace("AI Budget Space");
252 const page = await createPage(api, ws.id, { title: "Budget Probe" });
253 await authPage.goto(`/w/${ws.id}/page/${page.id}`);
254 await authPage.getByTestId("ask-ai-button").click();
255 const panel = authPage.getByTestId("ai-panel");
256 await expect(panel).toBeVisible();
257 const input = authPage.getByTestId("ai-input");
258 await expect(input).toBeVisible();
259
260
261
262 await setAiBudgetLimit(token, ws.id, 1);
263
264 await input.fill(QUESTION);
265 await authPage.getByTestId("ai-send").click();
266
267
268 const banner = authPage.getByTestId("ai-error-banner");
269 await expect(banner).toBeVisible({ timeout: 20_000 });
270 await expect(banner).toContainText("Monthly AI budget reached");
271 await expect(authPage.getByTestId("ai-retry")).toHaveCount(0);
272
273 await expect(authPage.getByTestId("ai-message-assistant")).toHaveCount(0);
274 });
275});
276