diff --git a/docs/plans/2026-06-22-generation-id-join-design.md b/docs/plans/2026-06-22-generation-id-join-design.md new file mode 100644 index 00000000..d4653956 --- /dev/null +++ b/docs/plans/2026-06-22-generation-id-join-design.md @@ -0,0 +1,379 @@ +# Engineering Design: Accept `generationId` on `POST /v2/agents/join` (convos-backend) + +## 1. Summary + +To let agent provisioning run in parallel with LLM template generation, the +iOS builder flow will call `POST /v2/agents/join` with a `generationId` instead +of waiting for generation to finish and passing a `templateId`. This spec covers +the **convos-backend** half of that change only: make the join handler accept +`generationId` as an alternative to `templateId`, validate the generation row's +existence and ownership, dispatch the assistant workflow **without** a resolved +template (forwarding `generationId` downstream), and confirm the existing +`GET /v2/agent-templates/:id` endpoint already serves any-status templates to the +agent-API-key caller so the workflow can resolve the generated template later. The +change is purely additive — the existing `templateId` and bare-join paths are +untouched. + +## 2. Project Goals & Non-Goals + +### Goals + +- `POST /v2/agents/join` SHALL accept `generationId` (a UUID) as an alternative + to `templateId`, dispatching the assistant workflow with `template: null` and a + forwarded `generationId`. +- The handler SHALL validate that the supplied `generationId` references an + existing generation row owned by the joining user, returning `404`/`403` + respectively otherwise — so a bad or foreign id fails at join time rather than + stranding a provisioned agent. +- The `agent-builder` onboarding option, which is rejected when combined with + `templateId`, SHALL be permitted (and is the expected pairing) when combined + with `generationId`. +- `templateId` and `generationId` SHALL be mutually exclusive ("at most one"). +- The direct-add registration-poll phase (`pollUntilRegistered` → return + `{instanceId, inboxId}`) SHALL be unchanged — registration does not depend on a + template. +- Confirm and lock in (with a regression test) that `GET /v2/agent-templates/:id` + already serves a **draft** template to a caller presenting the agent API key + (`isApiKeyListener`), so the convos-assistants workflow can resolve the + generated template by id regardless of its `publishStatus` at the moment + generation completes. **No new endpoint is added.** + +**Invariants that must hold:** + +- The `dispatchBodySchema` is `.strict()`: a `generationId` field must be added to + it or every gen-ID dispatch 500s. This is the existing deliberate forcing + function (`join.ts:144`). +- An agent is never provisioned without an `ownerAccountId` (the pre-#231 + ownerless-agent bug class). `ownerAccountId` continues to be the joining user's + account id on every path. + +### Non-Goals + +- **convos-assistants workflow changes** (poll-for-preview, poll-for-done, + `buildJoinIdentity` refactor, the convos-API client, container pre-start). Being + worked in parallel; out of scope here. +- **convos-ios changes** (reorder `drive`, models, protocol, mocks). +- **Orphaned-member cleanup on generation failure** (plan §5.6 / open decision + #3). The failure transition is owned by the assistants workflow; backend-driven + cleanup is a separate follow-up, explicitly out of scope. +- **A new `/internal/agent-templates/:id` endpoint.** The plan recommended one as + a hedge against draft-visibility timing; exploration shows the existing endpoint + already covers the keyed-worker case, so it is not built. +- The backend does **not** itself poll the generations API during join — that is + the workflow's job. The backend only forwards `generationId`. + +## 3. Context + +### Catalysts + +- Source design doc: `convos-ios/docs/plans/generation-id-join.md` (§4 is the + backend slice; §7 PR plan item 1 is this work). + +### Codebase + +- `src/api/v2/agents/handlers/join.ts` — the join handler (primary impact). +- `src/api/v2/agents/lib/build-join-payload.ts` — template→wire transform (used + only on the `templateId` path; untouched). +- `src/api/v2/agents/handlers/assistant-config.ts` — config getters + test seam. +- `src/api/v2/agents/agents.router.ts` — mounts `join` behind + `authMiddleware`/`requireAccount`. +- `src/api/v2/agent-templates/handlers/detail.ts` — `GET /v2/agent-templates/:id`; + serves drafts to owner **or** API-key listener (`:64-84`). +- `src/api/v2/agent-templates/agent-templates.router.ts` — mounts detail under + `optionalAuthOrAgentApiKeyAuth` (`:97-101`). +- `src/middleware/agentAuth.ts` — `optionalAuthOrAgentApiKeyAuth` sets + `res.locals.isApiKeyListener = true` for agent-API-key callers (`:166-175`). +- `prisma/schema.prisma` — `AgentTemplateGeneration` model (`:213-272`); status + enum `pending|running|done|failed` (`:206-211`); `ownerAccountId` (`:215`), + `templateId?` (`:256`), `preview?` (`:251`). **No `conversationId` column.** +- `src/api/v2/agent-templates/handlers/generations-post.ts:718-753` — establishes + that authenticated (JWT) builder submissions are owned by the real user account + (`getEffectiveOwnerId`), anonymous by `ADMIN_ACCOUNT_ID`. + +### Impact area + +- `src/api/v2/agents/handlers/join.ts` (schemas + resolution branch + dispatch + body build). +- `tests/agents-join.test.ts` (new scenarios). +- `tests/` — a regression test asserting the API-key listener can fetch a draft + template (anchors the workflow's resolution path). + +### Existing behavior at risk + +These behaviors in the impact area MUST continue working unchanged: + +- **Bare join** (no template, no generation): dispatches `template: null`, forwards + `ownerAccountId`. Covered by `tests/agents-join.test.ts:718-741`. +- **`templateId` resolution + publishStatus policy**: published/unlisted joinable + by anyone; draft joinable by owner only (403 otherwise); archived → 410; not + found → 404; lookup throw → 500. Covered `tests/agents-join.test.ts:743-899`. +- **`templateId` + `onboarding=agent-builder` → 400**: the existing mutual + exclusion. Covered `:901-914`. Must remain — only the _generationId_ pairing is + newly allowed. +- **Exactly-one-of slug/conversationId** invariant. Covered `:300-320`. +- **Direct-add registration poll** returns `{instanceId, inboxId}` / + null-pending. Covered `:179-298`. +- **`name`/`profileImage` override spread** onto the resolved template. Covered + `:780-813`. On the generationId path there is no template to spread onto. +- **`GET /v2/agent-templates/:id` draft visibility** to owner / API-key listener + / public-for-published. Covered by the detail handler's existing tests. + +### Brownfield gap analysis + +| Module | Path | Public interface the change conforms to / extends | Existing tests (verification anchors) | +| ---------------- | ------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------ | +| Join handler | `src/api/v2/agents/handlers/join.ts` | `bodySchema` (`:71`), `dispatchBodySchema` (`:144`), `joinHandler` (`:343`), `__setTemplateFinderForTests` (`:29`) | `tests/agents-join.test.ts` (whole file) | +| Assistant config | `src/api/v2/agents/handlers/assistant-config.ts` | `getAssistantApiUrl/Key`, `getJoinWaitBudgetMs/PollIntervalMs`, `assistantStatusSchema`, `__setAssistantConfigOverridesForTests` | exercised throughout `tests/agents-join.test.ts` | +| Template detail | `src/api/v2/agent-templates/handlers/detail.ts` | `detailHandler`; draft visible to `isApiKeyListener` (`:75-81`) | detail handler test suite | +| Generation model | `prisma/schema.prisma:213-272` | `prisma.agentTemplateGeneration.findUnique` | — | + +## 4. System Design + +### Architecture overview + +The change is localized to `joinHandler`. Today the handler has a single +template-resolution branch keyed on `templateId`. We add a parallel, mutually +exclusive branch keyed on `generationId` that validates the generation row but +resolves **no** template, leaving `resolvedTemplate = null` and forwarding +`generationId` in the dispatch body. + +``` +POST /v2/agents/join { conversationId, generationId, options:{onboarding:"agent-builder"} } + │ + ├─ bodySchema: at-most-one(templateId, generationId); exactly-one(slug, conversationId) + ├─ if templateId+agent-builder → 400 (unchanged) + │ (generationId+agent-builder → ALLOWED) + │ + ├─ if templateId present: resolve template + publishStatus policy (unchanged) + ├─ else if generationId present: + │ row = agentTemplateGeneration.findUnique({ where:{ id }}) + │ null → 404 GENERATION_NOT_FOUND + │ row.ownerAccountId !== caller → 403 GENERATION_FORBIDDEN + │ (do NOT resolve a template; resolvedTemplate stays null) + │ + ├─ build dispatch body: + │ template: joinPayload?.template ?? null (null on gen-ID path) + │ generationId: + │ ownerAccountId: caller + │ dispatchBodySchema(.strict()): now includes generationId + │ + └─ POST {assistant}/api/assistants → poll (direct-add: pollUntilRegistered) + → { instanceId, inboxId } (unchanged) + +Later, asynchronously (convos-assistants, OUT OF SCOPE): + workflow polls GET /v2/agent-templates/generations/:id (preview, then done→templateId) + workflow GET /v2/agent-templates/:templateId with X-Agent-API-Key + → detail.ts serves it (draft OK for isApiKeyListener) ← this spec only CONFIRMS this works +``` + +### New or modified interfaces + +**`bodySchema` (`join.ts:71`)** — add field + refinement: + +```ts +generationId: z.string().uuid().optional(), +// existing fields unchanged … +.refine(at-most-one(templateId, generationId), { + message: "Provide at most one of templateId or generationId", + path: ["generationId"], +}) +``` + +**`dispatchBodySchema` (`join.ts:144`)** — add field (`.strict()` forces this): + +```ts +generationId: z.string().uuid().optional(), +// template stays z.record(...).nullable() +``` + +**New error constants** (alongside `ERRORS` / inline, mirroring template policy): + +- `404 GENERATION_NOT_FOUND` — "Generation not found" +- `403 GENERATION_FORBIDDEN` — "Not authorized to use this generation" +- `500 GENERATION_LOOKUP_FAILED` — "Failed to load generation" (DB throw) + +### Key functions + +- **`joinHandler` resolution branch**: introduce an `else if (generationId !== +undefined)` arm after the existing `if (templateId !== undefined)` block + (`:465-541`). It performs `prisma.agentTemplateGeneration.findUnique({ where: { +id: generationId } })` inside a try/catch (DB throw → 500 + `GENERATION_LOOKUP_FAILED`, mirroring the templateId catch at `:468-479`); null + row → 404; `row.ownerAccountId !== joiningUserAccountId` → 403. It does **not** + read `row.status` or `row.templateId` — the generation may still be `pending`, + which is the whole point. `resolvedTemplate` remains `null`. + + _Owner-check soundness:_ authenticated builder submissions are owned by the real + user account (`generations-post.ts:753`), so for the normal JWT-authenticated + iOS builder flow `row.ownerAccountId === joiningUserAccountId`. (Anonymous + generations owned by `ADMIN_ACCOUNT_ID` are not part of the builder-join flow; + they would 403, which is correct — a different account cannot adopt them.) + + Following the established test-seam pattern (`__setTemplateFinderForTests`, + `:29`), add a `__setGenerationFinderForTests` seam so the generation lookup can + be injected in tests without a live DB. + +- **`agent-builder` gate generalization (`:446-457`)**: the existing rejection + fires only for `templateId + agent-builder`. Leave that exactly as-is; the + `generationId + agent-builder` combination simply never hits it, so it is + allowed with no code change to the gate beyond confirming the condition keys on + `templateId !== undefined` (it already does). + +- **Dispatch body build (`:591-603`)**: add `generationId` to `dispatchBody` when + present. On the gen-ID path `templateWithOverrides` is `null` (no template to + spread `name`/`profileImage` onto), so `joinPayload` is `null` and `template` + serializes to `null` — already the bare-join behavior. + +### Alternatives considered + +- **New `GET /internal/agent-templates/:id` behind a dedicated bearer token** + (plan §5.5/5.6 recommendation, modeled on credits-admin #321 + `makeBearerTokenAuth`). Rejected: `detail.ts:64-84` already serves drafts to + `isApiKeyListener`, and `optionalAuthOrAgentApiKeyAuth` already sets that flag + for the agent-API-key caller the workflow uses. A new route + middleware + token + provisioning would duplicate an existing, tested capability. The hardening value + (revoking the workflow's broad agent-API-key reach) is real but not needed for + this feature and can be a later follow-up. + +- **Validate the generation's `conversationId` matches the join's + `conversationId`.** Rejected: the `AgentTemplateGeneration` model has no + `conversationId` column (`schema.prisma:213-272`), so this binding does not + exist to check. Owner-equality is the available and sufficient guard. + +- **Resolve the generation's `templateId` eagerly when the row is already + `done`.** Rejected: couples the handler to generation timing, reintroduces the + serialization the feature removes, and the workflow already resolves the + template itself. The backend stays template-agnostic on this path. + +## 5. Libraries & Utilities Required + +**External dependencies:** None. + +**Internal modules:** + +| Module | Path | Purpose | +| ----------------- | ---------------------- | -------------------------------------------------------------------------------------------------- | +| `prisma` | `src/utils/prisma` | `agentTemplateGeneration.findUnique` for the existence/owner check (already imported in `join.ts`) | +| `accountIdSchema` | `src/utils/account-id` | already used by `dispatchBodySchema`; unchanged | + +No new dependencies, no schema migration (the `AgentTemplateGeneration` model and +`detail.ts` visibility already exist). + +## 6. Testing & Validation + +### Acceptance Criteria + +1. WHEN a join request includes a valid `generationId` owned by the caller AND a + `conversationId` THE SYSTEM SHALL dispatch `POST {assistant}/api/assistants` + with `template: null`, the forwarded `generationId`, and + `ownerAccountId` = the caller's account id. +2. WHEN a join request includes `generationId` for a generation row that does not + exist THE SYSTEM SHALL respond `404` with error `GENERATION_NOT_FOUND` and + SHALL NOT dispatch the assistant workflow. +3. WHEN a join request includes `generationId` for a generation row whose + `ownerAccountId` differs from the caller's account id THE SYSTEM SHALL respond + `403` with error `GENERATION_FORBIDDEN` and SHALL NOT dispatch the workflow. +4. WHEN the generation lookup throws (DB error) THE SYSTEM SHALL respond `500` with + error `GENERATION_LOOKUP_FAILED` and SHALL NOT dispatch the workflow. +5. WHEN a join request includes both `templateId` and `generationId` THE SYSTEM + SHALL respond `400` with error `INVALID_REQUEST`. +6. WHEN a join request includes `generationId` AND `options.onboarding` = + `agent-builder` THE SYSTEM SHALL accept the request (NOT respond 400) and + forward `options.onboarding` upstream. +7. WHEN a join request includes `generationId` for a generation whose `status` is + `pending` (no `templateId` yet) THE SYSTEM SHALL still dispatch successfully — + it SHALL NOT read or require `row.templateId` or `row.status`. +8. WHEN a join request includes `generationId` with a `conversationId` + (direct-add) THE SYSTEM SHALL poll for registration and respond + `{ success: true, joined: false, instanceId, inboxId }` exactly as the + no-template direct-add path does today. +9. THE SYSTEM SHALL include `generationId` in `dispatchBodySchema` such that a + gen-ID dispatch body passes `.strict()` validation rather than 500ing with + `JOIN_DISPATCH_INVALID`. +10. WHERE a caller presents a valid agent API key (`X-Agent-API-Key`) THE SYSTEM + SHALL serve a `draft` agent template from `GET /v2/agent-templates/:id` + regardless of the caller's account ownership of that template. + +### Regression Protection + +**Preserved behaviors (must NOT change):** + +- THE SYSTEM SHALL CONTINUE TO dispatch a bare join (no `templateId`, no + `generationId`) with `template: null` and the caller's `ownerAccountId`. +- THE SYSTEM SHALL CONTINUE TO resolve a `templateId`, apply the publishStatus + policy (published/unlisted → anyone; draft → owner-only else 403; archived → + 410; not found → 404; lookup throw → 500), and ride the template as the + top-level `template` field. +- THE SYSTEM SHALL CONTINUE TO reject `templateId` combined with + `options.onboarding=agent-builder` with `400 INVALID_REQUEST`. +- THE SYSTEM SHALL CONTINUE TO require exactly one of `slug` or `conversationId`. +- THE SYSTEM SHALL CONTINUE TO overlay caller-supplied `name`/`profileImage` onto + the resolved template on the `templateId` path. +- THE SYSTEM SHALL CONTINUE TO return `{ instanceId, inboxId }` (or null-pending) + for direct-add registration. +- THE SYSTEM SHALL CONTINUE TO serve published/unlisted templates publicly and + drafts to their owner from `GET /v2/agent-templates/:id`. + +**Verification anchors (must remain green):** + +- `tests/agents-join.test.ts` — entire suite, especially: + - bare join ownerAccountId forwarding (`:718-741`) + - templateId resolution + publishStatus policy (`:743-899`) + - templateId + agent-builder rejection (`:901-914`) + - templateId + first-impression composes (`:916-943`) + - slug/conversationId exclusivity (`:300-320`) + - direct-add registration poll (`:179-298`) +- `tests/build-join-payload.test.ts` — unchanged (gen-ID path doesn't touch + `buildJoinPayload`). +- The detail-handler test suite covering draft visibility. + +**Coverage gaps:** The "API-key listener can fetch a draft template" behavior +(AC-10) is the workflow's load-bearing assumption. If no existing detail-handler +test asserts the **draft + API-key** case specifically, add one BEFORE relying on +it, so the workflow's resolution path is anchored in convos-backend's suite. + +### Edge Cases + +- **`pending`/`running` generation at join time** — expected and must succeed + (AC-7); the handler must not branch on `status`. +- **`failed`/`done` generation at join time** — also accepted at the backend; the + backend does not gate on terminal status (cleanup/failure handling is the + workflow's job, out of scope). The owner+existence check is the only gate. +- **Generation owned by `ADMIN_ACCOUNT_ID` (anonymous submission)** — a + JWT-authenticated caller will 403 (not their account). Correct; documented as + intended. +- **Both `templateId` and `generationId` absent** — bare join; unchanged. +- **Malformed `generationId` (non-UUID)** — `z.string().uuid()` → `400 +INVALID_REQUEST` before any DB hit. +- **DB throw during generation lookup** — 500, no dispatch (AC-4), mirroring the + templateId lookup-failure path so a transient DB error doesn't strand a + provisioned agent. +- **Security**: the owner check prevents a caller from provisioning an agent bound + to another user's in-flight generation. `generationId` is logged (it is a UUID + reference, not a capability secret like `slug`); follow the existing sanitized + logging at `:411-417` (log `generationId`, never `slug`). + +### Verification Commands + +```bash +# Single-file, fastest feedback loop: +pnpm test -- tests/agents-join.test.ts + +# Detail-handler regression (draft + API-key): +pnpm test -- tests/agent-templates-detail.test.ts # adjust to actual filename + +# Full suite (DB-backed): +pnpm run test:local + +# Static checks (typecheck + prettier + eslint): +pnpm run check +# or individually: +pnpm run typecheck +pnpm run lint +pnpm run format:check +``` + +`.strict()` on `dispatchBodySchema` makes a missed `generationId` field a 500 at +runtime; AC-9's test (assert a gen-ID dispatch body passes validation and the +upstream `POST /api/assistants` receives `generationId`) is the guard that the +schema was actually extended. diff --git a/src/api/v2/agents/handlers/join.ts b/src/api/v2/agents/handlers/join.ts index 87f5f04e..e609fb7d 100644 --- a/src/api/v2/agents/handlers/join.ts +++ b/src/api/v2/agents/handlers/join.ts @@ -17,11 +17,18 @@ const AGENT_BUILDER_ONBOARDING = "agent-builder"; type TemplateRow = Awaited>; type TemplateFinder = (id: string) => Promise; +type GenerationRow = Awaited< + ReturnType +>; +type GenerationFinder = (id: string) => Promise; const defaultTemplateFinder: TemplateFinder = (id) => prisma.agentTemplate.findUnique({ where: { id } }); +const defaultGenerationFinder: GenerationFinder = (id) => + prisma.agentTemplateGeneration.findUnique({ where: { id } }); let _templateFinder: TemplateFinder = defaultTemplateFinder; +let _generationFinder: GenerationFinder = defaultGenerationFinder; // Test seam — substitute the per-id prisma lookup. Mirrors the // `__setAssistantConfigOverridesForTests` pattern in `./assistant-config.ts`. @@ -32,6 +39,12 @@ export function __setTemplateFinderForTests( _templateFinder = finder ?? defaultTemplateFinder; } +export function __setGenerationFinderForTests( + finder: GenerationFinder | null, +): void { + _generationFinder = finder ?? defaultGenerationFinder; +} + const timezoneSchema = z .string() .min(1) @@ -81,6 +94,7 @@ const bodySchema = z .transform((v) => v.toLowerCase()) .optional(), templateId: z.string().uuid().optional(), + generationId: z.string().uuid().optional(), name: z.string().min(1).max(256).optional(), profileImage: z.string().min(1).max(2048).optional(), options: optionsSchema.optional(), @@ -91,7 +105,14 @@ const bodySchema = z message: "Provide exactly one of slug (invite join) or conversationId (direct-add)", path: ["conversationId"], - }); + }) + .refine( + (b) => !(b.templateId !== undefined && b.generationId !== undefined), + { + message: "Provide at most one of templateId or generationId", + path: ["generationId"], + }, + ); const FORCE_ERROR_DELAY_MS = 5_000; @@ -151,6 +172,7 @@ const dispatchBodySchema = z .max(128) .optional(), template: z.record(z.string(), z.unknown()).nullable(), + generationId: z.string().uuid().optional(), ownerAccountId: accountIdSchema, options: optionsSchema.optional(), timezone: timezoneSchema.optional(), @@ -399,6 +421,7 @@ export async function joinHandler(req: Request, res: Response) { slug, conversationId, templateId, + generationId, name, profileImage, options, @@ -411,6 +434,7 @@ export async function joinHandler(req: Request, res: Response) { req.log.info( { templateId, + generationId, optionKeys: options ? Object.keys(options) : [], }, "Agent join request received", @@ -538,6 +562,48 @@ export async function joinHandler(req: Request, res: Response) { return; } } + } else if (generationId !== undefined) { + let generation: GenerationRow = null; + try { + generation = await _generationFinder(generationId); + } catch (err) { + req.log.error( + { err, generationId }, + "Failed to load agent template generation for join", + ); + res.status(500).json({ + success: false, + error: "GENERATION_LOOKUP_FAILED", + message: "Failed to load agent template generation", + }); + return; + } + + if (generation === null) { + res.status(404).json({ + success: false, + error: "GENERATION_NOT_FOUND", + message: "Agent template generation not found", + }); + return; + } + + if (generation.ownerAccountId !== joiningUserAccountId) { + req.log.warn( + { + generationId, + ownerAccountId: generation.ownerAccountId, + callerAccountId: joiningUserAccountId, + }, + "Caller is not the owner of an agent template generation", + ); + res.status(403).json({ + success: false, + error: "GENERATION_FORBIDDEN", + message: "Not authorized to use this generation", + }); + return; + } } const assistantBaseUrl = assistantApiUrl.replace(/\/+$/, ""); @@ -595,6 +661,9 @@ export async function joinHandler(req: Request, res: Response) { template: joinPayload?.template ?? null, ownerAccountId: joiningUserAccountId, }; + if (generationId !== undefined) { + dispatchBody.generationId = generationId; + } if (Object.keys(upstreamOptions).length > 0) { dispatchBody.options = upstreamOptions; } diff --git a/tests/agent-templates.detail.test.ts b/tests/agent-templates.detail.test.ts index 652b9a05..1330511b 100644 --- a/tests/agent-templates.detail.test.ts +++ b/tests/agent-templates.detail.test.ts @@ -9,11 +9,16 @@ import { expect, test, } from "vitest"; +import { __setAgentAssetsApiKeyOverrideForTests } from "@/middleware/agentAuth"; import { ADMIN_ACCOUNT_ID } from "@/utils/constants"; import { createJwtToken } from "@/utils/jwt"; import { prisma } from "@/utils/prisma"; import { hashId } from "@/utils/url-slug"; -import { buildAgentTemplatesApp } from "./agent-templates.cross.helpers"; +import { + agentKeyHeaders, + buildAgentTemplatesApp, + validAgentAssetsApiKey, +} from "./agent-templates.cross.helpers"; type DetailBody = Record; @@ -26,7 +31,9 @@ const isoTimestampPattern = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z$/; const cleanupTemplates = () => prisma.agentTemplate.deleteMany({ - where: { ownerAccountId: ADMIN_ACCOUNT_ID }, + where: { + ownerAccountId: { in: [ADMIN_ACCOUNT_ID, API_KEY_DRAFT_OWNER_ID] }, + }, }); const createTemplate = async ( @@ -63,6 +70,7 @@ const createTemplate = async ( // owner — templates are owned by ADMIN_ACCOUNT_ID; the reader sees only // published/unlisted/archived from other owners. const READER_ACCOUNT_ID = "00000000-0000-4000-8000-000000000002"; +const API_KEY_DRAFT_OWNER_ID = "00000000-0000-4000-8000-000000000003"; const readerAuthHeaders = async (): Promise> => ({ "X-Convos-AuthToken": await createJwtToken({ @@ -85,6 +93,7 @@ const readDetail = async (args: { path: string }) => { describe("Agent template detail endpoint", () => { beforeAll(async () => { + __setAgentAssetsApiKeyOverrideForTests(validAgentAssetsApiKey); await new Promise((resolve) => { server = app.listen(4053, () => { resolve(); @@ -99,6 +108,7 @@ describe("Agent template detail endpoint", () => { resolve(); }); }); + __setAgentAssetsApiKeyOverrideForTests(undefined); }); beforeEach(async () => { @@ -222,6 +232,44 @@ describe("Agent template detail endpoint", () => { } }); + test("returns a draft template to an agent API key caller", async () => { + await prisma.account.upsert({ + where: { id: API_KEY_DRAFT_OWNER_ID }, + update: {}, + create: { id: API_KEY_DRAFT_OWNER_ID }, + }); + + const draft = await createTemplate({ + slug: "detail-draft-apikey", + ownerAccountId: API_KEY_DRAFT_OWNER_ID, + status: "draft", + firstPublishedAt: null, + }); + + const reader = await readDetail({ + path: `/api/v2/agent-templates/${draft.id}`, + }); + expect(reader.response.status).toBe(404); + + const response = await fetch( + `${baseURL}/api/v2/agent-templates/${draft.id}`, + { headers: agentKeyHeaders() }, + ); + const body = (await response.json()) as DetailBody; + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toContain("application/json"); + expect(body).toMatchObject({ + object: "agent_template", + id: draft.id, + slug: "detail-draft-apikey", + ownerAccountId: API_KEY_DRAFT_OWNER_ID, + status: "draft", + }); + expect(body).not.toHaveProperty("owner"); + expect(body).not.toHaveProperty("skills"); + }); + test("supports owner and skills expansions while ignoring unknown expansion values", async () => { const tmpl = await createTemplate({ slug: "detail-expand", diff --git a/tests/agents-join.test.ts b/tests/agents-join.test.ts index fb4c28f5..1732249b 100644 --- a/tests/agents-join.test.ts +++ b/tests/agents-join.test.ts @@ -1,5 +1,5 @@ import type { Server } from "node:http"; -import type { AgentTemplate } from "@prisma/client"; +import type { AgentTemplate, AgentTemplateGeneration } from "@prisma/client"; import express, { type Response as ExpressResponse, type NextFunction, @@ -16,6 +16,7 @@ import { } from "vitest"; import { __setAssistantConfigOverridesForTests } from "@/api/v2/agents/handlers/assistant-config"; import { + __setGenerationFinderForTests, __setTemplateFinderForTests, joinHandler, } from "@/api/v2/agents/handlers/join"; @@ -79,6 +80,33 @@ const baseTemplate = ( ...overrides, }); +const baseGeneration = ( + overrides: Partial = {}, +): AgentTemplateGeneration => ({ + id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + ownerAccountId: "55555555-5555-4555-8555-555555555555", + source: "test", + idempotencyKey: "generation-test-key", + inputs: {}, + twitterContext: null, + clientDeviceId: null, + prefill: null, + builderPrompt: null, + builderModel: null, + connections: [], + preview: null, + progressPhrases: null, + templateId: null, + publishStatus: "draft", + reply: null, + status: "pending", + error: null, + expiresAt: null, + createdAt: new Date("2026-01-01T00:00:00.000Z"), + updatedAt: new Date("2026-01-01T00:00:00.000Z"), + ...overrides, +}); + function jsonResponse(status: number, body: unknown): Response { return new Response(JSON.stringify(body), { status, @@ -131,6 +159,7 @@ describe("agents join (assistant API)", () => { afterEach(() => { __setAssistantConfigOverridesForTests({}); + __setGenerationFinderForTests(null); __setTemplateFinderForTests(null); }); @@ -319,6 +348,39 @@ describe("agents join (assistant API)", () => { expect(dispatched).toBe(false); }); + test("rejects templateId combined with generationId", async () => { + let dispatched = false; + mockFetchImpl = () => { + dispatched = true; + return Promise.reject(new Error("should not dispatch")); + }; + + const res = await post({ + slug: "x", + templateId: "33333333-3333-4333-8333-333333333333", + generationId: "44444444-4444-4444-8444-444444444444", + }); + expect(res.status).toBe(400); + const data = (await res.json()) as { success: boolean; error: string }; + expect(data.success).toBe(false); + expect(data.error).toBe("INVALID_REQUEST"); + expect(dispatched).toBe(false); + }); + + test("rejects a non-UUID generationId", async () => { + let dispatched = false; + mockFetchImpl = () => { + dispatched = true; + return Promise.reject(new Error("should not dispatch")); + }; + + const res = await post({ slug: "x", generationId: "not-a-uuid" }); + expect(res.status).toBe(400); + const data = (await res.json()) as { error: string }; + expect(data.error).toBe("INVALID_REQUEST"); + expect(dispatched).toBe(false); + }); + test("slug join: a poll that lands on 'ready' counts as joined", async () => { // The runtime advances joined → ready when boot completes; a poll can // observe only the latter. Treating it as not-joined burned the whole @@ -942,6 +1004,188 @@ describe("agents join (assistant API)", () => { expect(res.status).toBe(200); }); + // ----- generationId resolution ----- + + test("returns 404 when the generation row is missing", async () => { + __setGenerationFinderForTests(() => Promise.resolve(null)); + let dispatched = false; + mockFetchImpl = () => { + dispatched = true; + return Promise.reject(new Error("should not dispatch")); + }; + + const res = await post( + { + conversationId: DIRECT_ADD_CONVERSATION_ID, + generationId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + }, + { accountId: "55555555-5555-4555-8555-555555555555" }, + ); + expect(res.status).toBe(404); + const data = (await res.json()) as { error: string }; + expect(data.error).toBe("GENERATION_NOT_FOUND"); + expect(dispatched).toBe(false); + }); + + test("returns 403 when the generation row belongs to another account", async () => { + __setGenerationFinderForTests(() => + Promise.resolve( + baseGeneration({ + ownerAccountId: "66666666-6666-4666-8666-666666666666", + }), + ), + ); + let dispatched = false; + mockFetchImpl = () => { + dispatched = true; + return Promise.reject(new Error("should not dispatch")); + }; + + const res = await post( + { + conversationId: DIRECT_ADD_CONVERSATION_ID, + generationId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + }, + { accountId: "55555555-5555-4555-8555-555555555555" }, + ); + expect(res.status).toBe(403); + const data = (await res.json()) as { error: string }; + expect(data.error).toBe("GENERATION_FORBIDDEN"); + expect(dispatched).toBe(false); + }); + + test("returns 500 when the generation lookup throws", async () => { + __setGenerationFinderForTests(() => + Promise.reject(new Error("ECONNREFUSED")), + ); + let dispatched = false; + mockFetchImpl = () => { + dispatched = true; + return Promise.reject(new Error("should not dispatch")); + }; + + const res = await post( + { + conversationId: DIRECT_ADD_CONVERSATION_ID, + generationId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + }, + { accountId: "55555555-5555-4555-8555-555555555555" }, + ); + expect(res.status).toBe(500); + const data = (await res.json()) as { error: string }; + expect(data.error).toBe("GENERATION_LOOKUP_FAILED"); + expect(dispatched).toBe(false); + }); + + test("dispatches a null template, the generationId, and the caller ownerAccountId", async () => { + const generationId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; + const accountId = "55555555-5555-4555-8555-555555555555"; + let lookedUpGenerationId: string | undefined; + __setGenerationFinderForTests((id) => { + lookedUpGenerationId = id; + return Promise.resolve( + baseGeneration({ id, ownerAccountId: accountId }), + ); + }); + + let dispatchedBody: Record | undefined; + mockFetchImpl = (_url, init) => { + if (init?.method === "POST") { + dispatchedBody = JSON.parse(init.body as string) as Record< + string, + unknown + >; + return Promise.resolve(jsonResponse(200, { instanceId: "inst-gen" })); + } + return Promise.resolve( + jsonResponse(200, { + instanceId: "inst-gen", + joinStatus: "starting", + inboxId: "inbox-gen", + }), + ); + }; + + const res = await post( + { conversationId: DIRECT_ADD_CONVERSATION_ID, generationId }, + { accountId }, + ); + expect(res.status).toBe(200); + expect(lookedUpGenerationId).toBe(generationId); + expect(dispatchedBody?.generationId).toBe(generationId); + expect(dispatchedBody?.template).toBeNull(); + expect(dispatchedBody?.ownerAccountId).toBe(accountId); + }); + + test("accepts generationId combined with onboarding agent-builder and forwards it", async () => { + __setGenerationFinderForTests(() => Promise.resolve(baseGeneration())); + + let dispatchedOptions: Record | undefined; + mockFetchImpl = (_url, init) => { + if (init?.method === "POST") { + const body = JSON.parse(init.body as string) as { + options?: Record; + }; + dispatchedOptions = body.options; + return Promise.resolve(jsonResponse(200, { instanceId: "inst-ab" })); + } + return Promise.resolve( + jsonResponse(200, { + instanceId: "inst-ab", + joinStatus: "starting", + inboxId: "inbox-ab", + }), + ); + }; + + const res = await post( + { + conversationId: DIRECT_ADD_CONVERSATION_ID, + generationId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + options: { onboarding: "agent-builder" }, + }, + { accountId: "55555555-5555-4555-8555-555555555555" }, + ); + expect(res.status).toBe(200); + expect(dispatchedOptions?.onboarding).toBe("agent-builder"); + }); + + test("returns the registered inboxId for a generationId direct-add join", async () => { + __setGenerationFinderForTests(() => Promise.resolve(baseGeneration())); + + mockFetchImpl = (_url, init) => { + if (init?.method === "POST") { + return Promise.resolve(jsonResponse(200, { instanceId: "inst-da" })); + } + return Promise.resolve( + jsonResponse(200, { + instanceId: "inst-da", + joinStatus: "starting", + inboxId: "inbox-da", + }), + ); + }; + + const res = await post( + { + conversationId: DIRECT_ADD_CONVERSATION_ID, + generationId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + }, + { accountId: "55555555-5555-4555-8555-555555555555" }, + ); + expect(res.status).toBe(200); + const data = (await res.json()) as { + success: boolean; + joined: boolean; + instanceId: string; + inboxId: string | null; + }; + expect(data.success).toBe(true); + expect(data.joined).toBe(false); + expect(data.instanceId).toBe("inst-da"); + expect(data.inboxId).toBe("inbox-da"); + }); + test("dispatch body carries the joining user's uuid ownerAccountId", async () => { let capturedBody: Record | null = null; mockFetchImpl = (url, init) => { diff --git a/tests/telemetry-metrics.test.ts b/tests/telemetry-metrics.test.ts index a76a2e68..aedf71a4 100644 --- a/tests/telemetry-metrics.test.ts +++ b/tests/telemetry-metrics.test.ts @@ -30,7 +30,7 @@ function makeApp() { return app; } -const BATCH_ID = "44444444-4444-4444-8444-444444444444"; +const BATCH_ID = "77777777-7777-4777-8777-777777777777"; function makeBody(timeMs = Date.now() - 60_000) { const nanos = (n: number) => (BigInt(n) * 1_000_000n).toString(); @@ -225,9 +225,13 @@ describe("POST /telemetry/metrics", () => { vi.mocked(forwardMetrics).mockResolvedValue(false); const res = await post(makeApp()).send(makeBody()); expect(res.status).toBe(502); - expect( - await prisma.telemetryBatch.findUnique({ where: { batchId: BATCH_ID } }), - ).toBeNull(); + await vi.waitFor(async () => { + expect( + await prisma.telemetryBatch.findUnique({ + where: { batchId: BATCH_ID }, + }), + ).toBeNull(); + }); expect(countTelemetryBatch).toHaveBeenCalledTimes(1); expect(countTelemetryBatch).toHaveBeenCalledWith( "convos-android",