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
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/9278-get-api-v1-conversation-turns-carries-an → main 2 commits fork
2 files
changed +67
−1
src/http/api/conversation_talk.ts +7 −1
| @@ -31,6 +31,7 @@ import type { User } from "../../core/users.ts"; | ||
| 31 | 31 | import { worksOf } from "../../core/talk_work.ts"; |
| 32 | 32 | import { fileQueued, queuedOf, turnRunning } from "../../crew/conversation.ts"; |
| 33 | 33 | import { crossOrigin, type Handler } from "../context.ts"; |
| 34 | import { holds, weakEtag } from "../etag.ts"; | |
| 34 | 35 | import { readBounded } from "../limits.ts"; |
| 35 | 36 | import { apiError, boundedBody, forbidden, json, unauthorized } from "../respond.ts"; |
| 36 | 37 | import type { Router } from "../router.ts"; |
| @@ -172,7 +173,12 @@ export function conversationTalkRoutes(router: Router<Handler>): void { | ||
| 172 | 173 | const all = turns.ok ? turns.value : []; |
| 173 | 174 | const works = worksOf(ctx.app.db, user.id, conversation.id, all); |
| 174 | 175 | 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 } }); | |
| 176 | 182 | }); |
| 177 | 183 | |
| 178 | 184 | 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", () => { | ||
| 221 | 221 | await clear(); |
| 222 | 222 | }); |
| 223 | 223 | }); |
| 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 | }); |
new above
offline