Public: anyone can read this; the crew can change it.

GET /api/v1/conversation/turns carries an ETag and answers a repeat with 304 until a turn, a queued message or the running state changes #9287

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

mason/forge:mason/9278-get-api-v1-conversation-turns-carries-an → main 2 commits fork

  • Merged. mason/9278-get-api-v1-conversation-turns-carries-an was merged into main as a727eac.

Review

Sign in to review this change.
2 files changed +67 −1
src/http/api/conversation_talk.ts +7 −1
@@ -31,6 +31,7 @@ import type { User } from "../../core/users.ts";
3131 import { worksOf } from "../../core/talk_work.ts";
3232 import { fileQueued, queuedOf, turnRunning } from "../../crew/conversation.ts";
3333 import { crossOrigin, type Handler } from "../context.ts";
34 import { holds, weakEtag } from "../etag.ts";
3435 import { readBounded } from "../limits.ts";
3536 import { apiError, boundedBody, forbidden, json, unauthorized } from "../respond.ts";
3637 import type { Router } from "../router.ts";
@@ -172,7 +173,12 @@ export function conversationTalkRoutes(router: Router<Handler>): void {
172173 const all = turns.ok ? turns.value : [];
173174 const works = worksOf(ctx.app.db, user.id, conversation.id, all);
174175 const shown = all.map((t) => (t.role === "agent" ? { ...t, work: works.get(t.id) ?? null } : t));
175 return json({ running: turnRunning(ctx.app, user.id, conversation.id), conversation: conversation.id, queued: queuedOf(ctx.app, user.id, conversation.id).map((q) => ({ id: q.id, text: q.text })), turns: shown });
176 // The tag is of the exact bytes sent (so `before` and `limit` give their own), made after the person's own access was settled above.
177 const text = JSON.stringify({ running: turnRunning(ctx.app, user.id, conversation.id), conversation: conversation.id, queued: queuedOf(ctx.app, user.id, conversation.id).map((q) => ({ id: q.id, text: q.text })), turns: shown });
178 const etag = weakEtag(text);
179 const headers = { etag, "cache-control": "private", vary: "authorization, cookie" };
180 if (holds(ctx.request, etag)) return new Response(null, { status: 304, headers });
181 return new Response(text, { headers: { "content-type": "application/json", ...headers } });
176182 });
177183
178184 router.add("GET", "/api/v1/conversations", (ctx) => {
test/conversation-queue-api.test.ts +60
@@ -221,3 +221,63 @@ describe("queue and steer through the doors", () => {
221221 await clear();
222222 });
223223 });
224
225 describe("the turns carry an ETag (#9278)", () => {
226 const read = (extra: Record<string, string> = {}, query = "") =>
227 fetch(`${servers.url}/api/v1/conversation/turns${query}`, { headers: { authorization: `Bearer ${rw}`, ...extra } });
228
229 test("a repeat is a bodiless 304; a queued or cancelled message and a run starting or ending change the tag", async () => {
230 const first = await read();
231 expect(first.status).toBe(200);
232 await first.text();
233 const idle = first.headers.get("etag")!;
234 expect(idle).toMatch(/^W\//);
235 expect(first.headers.get("cache-control")).toBe("private");
236 const again = await read({ "if-none-match": idle });
237 expect(again.status).toBe(304);
238 expect(await again.text()).toBe("");
239 expect(again.headers.get("etag")).toBe(idle);
240 expect(again.headers.get("cache-control")).toBe("private");
241 expect(again.headers.get("x-content-type-options")).toBe("nosniff");
242 expect(again.headers.get("referrer-policy")).toBeTruthy();
243 expect(again.headers.get("content-security-policy")).toBeTruthy();
244
245 await start();
246 const running = await read({ "if-none-match": idle });
247 expect(running.status).toBe(200);
248 await running.text();
249 const runTag = running.headers.get("etag")!;
250 expect(runTag).not.toBe(idle);
251 expect((await read({ "if-none-match": runTag })).status).toBe(304);
252
253 const queued = (await (await send({ text: "later", during: "queue" })).json()) as { queued: string };
254 const waiting = await read({ "if-none-match": runTag });
255 expect(waiting.status).toBe(200);
256 await waiting.text();
257 const waitTag = waiting.headers.get("etag")!;
258 expect(waitTag).not.toBe(runTag);
259
260 const del = await fetch(`${servers.url}/api/v1/conversation/queue/${queued.queued}`, { method: "DELETE", headers: headers(rw) });
261 expect(del.status).toBe(200);
262 const cancelled = await read({ "if-none-match": waitTag });
263 expect(cancelled.status).toBe(200);
264 await cancelled.text();
265 expect(cancelled.headers.get("etag")).toBe(runTag);
266
267 await clear();
268 const ended = await read({ "if-none-match": runTag });
269 expect(ended.status).toBe(200);
270 await ended.text();
271 expect(ended.headers.get("etag")).not.toBe(runTag);
272 });
273
274 test("`limit` and `before` give different tags, and a tag from another person's read never matches", async () => {
275 const a = await read({}, "");
276 await a.text();
277 const b = await read({}, "?limit=1");
278 await b.text();
279 expect(b.headers.get("etag")).not.toBe(a.headers.get("etag"));
280 const other = await fetch(`${servers.url}/api/v1/conversation/turns`, { headers: { authorization: `Bearer ${bob}`, "if-none-match": a.headers.get("etag")! } });
281 expect(other.status).toBe(200);
282 });
283 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs