diff --git a/create-agent-app/index.mjs b/create-agent-app/index.mjs index c23826b..d282a4f 100644 --- a/create-agent-app/index.mjs +++ b/create-agent-app/index.mjs @@ -1,8 +1,9 @@ #!/usr/bin/env node // create-agent-app — scaffold a new Tangle agent product on @tangle-network/agent-app. // -// Dependency-light by design: Node built-ins only. The CLI copies the `template/` -// tree verbatim, substitutes a small set of `__TOKEN__` placeholders, and renames +// Dependency-light by design: Node built-ins only. The CLI copies one template +// tree verbatim (`template/` by default, `template-chat/` with `--chat`), +// substitutes a small set of `__TOKEN__` placeholders, and renames // files whose template name would otherwise interfere with tooling (a template's // own `package.json` must not be read by the scaffolder's package manager; a // template `gitignore` must not be applied to the scaffolder repo). The generated @@ -17,7 +18,13 @@ import { dirname, join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' const HERE = dirname(fileURLToPath(import.meta.url)) -const TEMPLATE_DIR = join(HERE, 'template') +// Template variants: the default tool-loop skeleton, and `--chat` — the +// assembled multimodal chat vertical (auth + chat-store + chat-routes + +// sandbox producer + uploads + replay) from `examples/chat-app.md`. +const TEMPLATES = { + default: join(HERE, 'template'), + chat: join(HERE, 'template-chat'), +} // The agent-app version range the generated project depends on. Kept as a single // constant so a release bump touches one line. @@ -39,6 +46,7 @@ function parseArgs(argv) { const a = argv[i] if (a === '--name') args.name = argv[++i] else if (a === '--agent-app-version') args.agentAppVersion = argv[++i] + else if (a === '--chat') args.template = 'chat' else if (a === '--force') args.force = true else if (a === '-h' || a === '--help') args.help = true else if (!a.startsWith('-')) args._.push(a) @@ -54,6 +62,10 @@ function usage() { 'Scaffolds a new Tangle agent product on @tangle-network/agent-app.', '', 'Options:', + ' --chat Scaffold the multimodal chat variant instead: the', + ' assembled chat vertical (auth, thread/message store,', + ' streaming turns + replay, uploads, agent asks) with', + ' its own end-to-end test. Default: the tool-loop skeleton.', ' --name Project name (default: the target dir basename).', ' --agent-app-version @tangle-network/agent-app version (default: ' + AGENT_APP_RANGE + ').', ' --force Write into a non-empty directory.', @@ -62,7 +74,7 @@ function usage() { 'After scaffolding:', ' cd && pnpm install', ' pnpm typecheck && pnpm test', - ' # then follow CUSTOMIZE.md: fill agent.config.ts, seed knowledge/, run pnpm knowledge:ingest', + ' # then follow CUSTOMIZE.md (each template ships its own fill-checklist)', ].join('\n') } @@ -110,6 +122,7 @@ async function main() { const projectName = args.name ?? targetDir.split(/[\\/]/).pop() const packageName = toPackageName(projectName) const agentAppVersion = args.agentAppVersion ?? AGENT_APP_RANGE + const templateDir = TEMPLATES[args.template ?? 'default'] if (existsSync(targetDir)) { const entries = await readdir(targetDir).catch(() => []) @@ -129,9 +142,9 @@ async function main() { AGENT_APP_VERSION: agentAppVersion, } - const files = await walk(TEMPLATE_DIR) + const files = await walk(templateDir) for (const rel of files) { - const src = join(TEMPLATE_DIR, rel) + const src = join(templateDir, rel) // Resolve any renamed path segments (only basenames are renamed). const parts = rel.split(/[\\/]/) const baseName = parts[parts.length - 1] diff --git a/create-agent-app/package.json b/create-agent-app/package.json index 6a94785..0c8979a 100644 --- a/create-agent-app/package.json +++ b/create-agent-app/package.json @@ -1,7 +1,7 @@ { "name": "@tangle-network/create-agent-app", "version": "0.1.0", - "description": "Scaffold a new Tangle agent product on @tangle-network/agent-app: a filled agent.config skeleton, a wired chat route over the Cloudflare preset, an empty knowledge/ dir, and the agent-followable breadcrumb docs (AGENTS.md / CUSTOMIZE.md / KNOWLEDGE.md).", + "description": "Scaffold a new Tangle agent product on @tangle-network/agent-app: the tool-loop skeleton (default) or, with --chat, the assembled multimodal chat vertical (auth, chat store, streaming turns with replay, uploads, agent asks) with its own end-to-end test — plus the agent-followable breadcrumb docs (AGENTS.md / CUSTOMIZE.md).", "keywords": [ "tangle", "ai-agent", @@ -22,7 +22,8 @@ }, "files": [ "index.mjs", - "template" + "template", + "template-chat" ], "publishConfig": { "access": "public" diff --git a/create-agent-app/template-chat/.dev.vars.example b/create-agent-app/template-chat/.dev.vars.example new file mode 100644 index 0000000..85ff7db --- /dev/null +++ b/create-agent-app/template-chat/.dev.vars.example @@ -0,0 +1,18 @@ +# Copy to .dev.vars for local development (never commit .dev.vars). +# Production: `wrangler secret put ` for each secret. + +# better-auth HMAC secret — generate one: openssl rand -base64 32 +BETTER_AUTH_SECRET=REPLACE_WITH_RANDOM_SECRET + +# Tangle Router key the harness bills model calls against. +TANGLE_API_KEY= +# Optional router override; omit for the platform default. +# TANGLE_ROUTER_URL= + +# Sandbox gateway credentials — the agent runs in a Tangle sandbox. Without +# these every turn fails loud (there is no mock agent). +SANDBOX_API_KEY= +SANDBOX_GATEWAY_URL= + +# Model override without a redeploy (falls back to config.model.default). +# MODEL_NAME= diff --git a/create-agent-app/template-chat/AGENTS.md b/create-agent-app/template-chat/AGENTS.md new file mode 100644 index 0000000..abadadb --- /dev/null +++ b/create-agent-app/template-chat/AGENTS.md @@ -0,0 +1,73 @@ +# AGENTS.md — you are customizing a chat agent-app + +You are a coding agent working in a project generated by `create-agent-app --chat`. +This project is a thin customization layer on top of `@tangle-network/agent-app` +(the shell): the whole server chat vertical — auth, thread/message persistence, +streaming turns with buffered replay, multimodal uploads, human-in-the-loop asks — +is ASSEMBLED from shell factories, not written here. Walk this contract before you +touch anything. It is a checklist, not prose — follow it in order. + +## 0. Orient + +- [ ] Read this file end to end. +- [ ] Read `CUSTOMIZE.md` — the ordered fill-checklist. It is your task list. +- [ ] Run `pnpm install && pnpm typecheck && pnpm test`. Confirm green BEFORE editing. + The test suite includes the end-to-end turn gate (fake sandbox producer → + streamed turn → persisted transcript) — it proves the assembly, not stubs. + +## 1. The one rule (layering) + +> You change behavior by editing DATA and the COMPOSER — never by editing +> `@tangle-network/agent-app`. + +- The shell owns mechanism: turn protocol + hook order (`createChatTurnRoutes` + over agent-runtime's `handleChatTurn`), persistence columns (`createChatTables`), + the upload inline-vs-sandbox split, the interaction answer contract, auth guards. +- You own: `agent.config.ts` + `prompts/system.md` (DATA), `src/` (the COMPOSER + + routes), `migrations/` (must mirror the schema — the e2e test executes it), + `public/` (the dev page). That is the whole surface. + +## 2. DATA vs CODE — know which file you're in + +- [ ] DATA → `agent.config.ts`: name, system prompt, model default + effort, + harness, renderable ask kinds. Plain values. If you're writing an `if`, + you're in the wrong file. +- [ ] DATA → `prompts/system.md`: the persona. State intents and hard rules, + never implementations — no shell commands, CLI flags, or install scripts. + The executing agent chooses tools at execution time. +- [ ] CODE → `src/chat.ts`: the COMPOSER. Wires config + env into the shell's + factories. Extend seams here (billing hooks, `transformFinalText`, + `onTurnComplete`); never re-implement what a factory already does. +- [ ] CODE → `src/sandbox.ts`: the sandbox lane (box naming, credentials, + profile). All agent intelligence lives IN the sandbox; this file only + reaches it. +- [ ] CODE → `src/worker.ts`: routing only. New endpoint = new handler in + `src/chat.ts`, one `if` here. + +## 3. Invariants — fail-closed, never relax + +- [ ] TRUSTED CONTEXT: `userId`/`workspaceId` come from the better-auth SESSION, + never from a request body or model output. The `authorize` seam is the only + place identity is established — keep it that way. +- [ ] THREAD ACCESS: an inaccessible thread reads as 404, indistinguishable from + a missing one. A cross-workspace probe must not learn the thread exists. +- [ ] NO MOCK AGENT: without sandbox credentials a turn fails loud with a clear + error. Never add a canned-response fallback — a fake answer is worse than + an honest failure. +- [ ] AGENT-NATIVE: intelligence and tooling live in the sandboxed agent; + durability (rows, turn buffer) and money live here. If you're about to + parse the agent's prose for data, stop — that's a schema-validated tool's + job (see the shell's `/tools`). +- [ ] GROUNDING: the persona's fabrication rule stays. A real record or an + explicit "NOT ON FILE". + +## 4. Verify (every change ends here) + +- [ ] `pnpm typecheck` — clean. Proves the assembly matches the shell contract. +- [ ] `pnpm test` — green. The e2e gate runs the REAL migration + REAL factories + with one fake (the sandbox event feed). If it fails, the app drifted from + the framework — fix the drift, not the test. +- [ ] For a real deploy: fill `wrangler.toml` + `.dev.vars`, run + `pnpm db:migrate:local`, then `pnpm dev` and exercise the dev page. + +If any of these is red, you are not done. Do not weaken a test to pass. diff --git a/create-agent-app/template-chat/CLAUDE.md b/create-agent-app/template-chat/CLAUDE.md new file mode 100644 index 0000000..8bc13a8 --- /dev/null +++ b/create-agent-app/template-chat/CLAUDE.md @@ -0,0 +1,6 @@ +# CLAUDE.md + +The behavior contract for this project lives in `AGENTS.md`. Read it first, then +walk `CUSTOMIZE.md` (the fill-checklist). + +@AGENTS.md diff --git a/create-agent-app/template-chat/CUSTOMIZE.md b/create-agent-app/template-chat/CUSTOMIZE.md new file mode 100644 index 0000000..40e6fb0 --- /dev/null +++ b/create-agent-app/template-chat/CUSTOMIZE.md @@ -0,0 +1,88 @@ +# CUSTOMIZE.md — fill this project, in order + +This is the trail. Walk it top to bottom. Each step is a checklist item paired +with the DISCOVERY QUESTION it answers — answer the question, then make the +edit. The whole job is filling `agent.config.ts` + `prompts/system.md` (DATA) +and wiring credentials; the chat vertical itself is already assembled and +proven by the e2e test. + +When every box is checked and `pnpm typecheck && pnpm test` are green and a +message round-trips on `pnpm dev`, the app is customized. + +--- + +## ① Identity — `agent.config.ts` + `prompts/system.md` + +Discovery: **Whose job does this agent do, in whose voice, under what hard rules?** + +- [ ] Set `name` in `agent.config.ts` to the product/agent name. +- [ ] Rewrite `prompts/system.md` as the real persona: role + voice + remit + + hard rules. State intents, never implementations (no commands, no tool + scripts — the sandboxed agent picks its own tools). +- [ ] Keep the grounding rule (never fabricate; cite a record or NOT ON FILE). + +## ② Model + harness — `agent.config.ts` → `model`, `harness` + +Discovery: **Which model answers by default, at what effort, on which harness?** + +- [ ] Set `model.default` to a model your Tangle Router key can reach. +- [ ] Pick `harness` (`opencode` default; vendor-locked harnesses like + `claude-code` must pair with their own provider's models). +- [ ] Leave per-turn overrides alone — the client can send `model`/`effort` + per request and `MODEL_NAME` overrides without a redeploy. + +## ③ Infrastructure — `wrangler.toml` + `.dev.vars` + `migrations/` + +Discovery: **Where does this app live and what may it spend?** + +- [ ] `wrangler d1 create ` → paste `database_id` into `wrangler.toml`. +- [ ] Copy `.dev.vars.example` → `.dev.vars`; fill `BETTER_AUTH_SECRET`, + `TANGLE_API_KEY`, `SANDBOX_API_KEY`, `SANDBOX_GATEWAY_URL`. +- [ ] `pnpm db:migrate:local` — applies `migrations/0001_init.sql` (auth + + chat + turn buffer). The e2e test executes this same file, so it cannot + silently drift from the schema. +- [ ] R2 stays commented out unless the product stores artifacts. + +## ④ Prove the loop — `pnpm dev` + +Discovery: **Does a real message round-trip through a real box?** + +- [ ] `pnpm dev`, open http://localhost:8787, sign up, send a message. +- [ ] Confirm: streamed text renders live, the transcript reloads with parts + and a usage receipt, and a second turn continues the same agent session. +- [ ] Kill the tab mid-turn, reopen the thread — the persisted row is intact + (the turn keeps running server-side and buffers for replay). + +## ⑤ The product UI — replace the dev page + +Discovery: **What does the real surface look like?** + +- [ ] `public/index.html` is the dev harness, not the product. Build the real + surface in React on `@tangle-network/agent-app/web-react`: + `ChatComposer` (`onSendParts` + `onAttach`), `ChatMessages`, + `streamChatTurn` (start + resume against `/api/chat/replay/:turnId`), + `useChatInteractions` + `InteractionQuestionCard`/`InteractionPlanCard` + for the ask channel this server already exposes. +- [ ] Keep the wire contract: `chatTurnRequestInit` from web-react serializes + exactly what `createChatTurnRoutes` parses. + +## ⑥ Extend, don't fork + +Discovery: **What does this product add beyond the vertical?** + +- [ ] Billing/audit/titles → `onTurnComplete` seam in `src/chat.ts`. +- [ ] PII scrubbing before persistence → `transformFinalText` + + `@tangle-network/agent-app/redact`. +- [ ] Structured agent writes (proposals, records) → the shell's `/tools` + side channel, never prose parsing. +- [ ] Multi-user teams → `@tangle-network/agent-app/teams` and pass your + workspace table to `createChatTables({ workspaceTable })` (new migration). + +## ⑦ Verify + +Discovery: **Does the customized app hold its contract?** + +- [ ] `pnpm typecheck` — clean. +- [ ] `pnpm test` — green (the e2e turn gate + fail-closed auth). +- [ ] `pnpm dev` — a real turn round-trips end to end. +- [ ] `pnpm deploy` + `pnpm db:migrate` when it's real. diff --git a/create-agent-app/template-chat/README.md b/create-agent-app/template-chat/README.md new file mode 100644 index 0000000..8751bc2 --- /dev/null +++ b/create-agent-app/template-chat/README.md @@ -0,0 +1,50 @@ +# __PROJECT_NAME__ + +A multimodal chat agent product scaffolded with `create-agent-app --chat`, built +on [`@tangle-network/agent-app`](https://github.com/tangle-network/agent-app): +the whole server chat vertical — better-auth sessions, thread/message +persistence with typed parts + usage receipts, streaming turns with buffered +replay, file uploads, human-in-the-loop asks — assembled from shell factories. +The agent itself runs in a Tangle sandbox (a full harness: skills, tools, bash, +MCP); this app coordinates UI, durability, and access around it. + +## Layout + +| Path | Surface | Edit when | +|---|---|---| +| `agent.config.ts` | DATA — name, model default, harness, ask kinds | defining the agent | +| `prompts/system.md` | DATA — the persona (imported as a Text module) | shaping behavior | +| `src/chat.ts` | CODE — the composer (factories → the chat vertical) | extending seams | +| `src/sandbox.ts` | CODE — the sandbox lane (boxes, credentials, profile) | provisioning changes | +| `src/worker.ts` | CODE — HTTP routing only | adding an endpoint | +| `src/db/schema.ts` | CODE — auth tables + `createChatTables()` | schema changes (+ migration) | +| `migrations/` | SQL the e2e test executes for real | schema changes | +| `public/index.html` | the dev chat page (not the product UI) | never — replace it (CUSTOMIZE ⑤) | +| `tests/` | the e2e turn gate this app ships with | extending coverage | + +## Get started + +```bash +pnpm install +pnpm typecheck && pnpm test # green before you edit anything +``` + +Then walk the trail: + +1. `AGENTS.md` — the behavior contract (you are customizing a chat agent-app). +2. `CUSTOMIZE.md` — the ordered fill-checklist, from persona to deploy. + +## Scripts + +- `pnpm dev` — run the worker + dev page locally (fill `wrangler.toml` + `.dev.vars` first). +- `pnpm db:migrate:local` / `pnpm db:migrate` — apply `migrations/` to D1. +- `pnpm typecheck` — `tsc --noEmit`. +- `pnpm test` — vitest, including the end-to-end turn gate (fake sandbox + producer → streamed turn → persisted transcript → replay). +- `pnpm deploy` — `wrangler deploy`. + +## Invariants + +Identity comes from the session, never a request body. Inaccessible threads +read as 404. No mock agent — missing sandbox credentials fail loud. See +`AGENTS.md` for the full contract. diff --git a/create-agent-app/template-chat/_gitignore b/create-agent-app/template-chat/_gitignore new file mode 100644 index 0000000..e31f359 --- /dev/null +++ b/create-agent-app/template-chat/_gitignore @@ -0,0 +1,6 @@ +node_modules/ +dist/ +.wrangler/ +.dev.vars +*.local +.DS_Store diff --git a/create-agent-app/template-chat/_package.json b/create-agent-app/template-chat/_package.json new file mode 100644 index 0000000..2aea46f --- /dev/null +++ b/create-agent-app/template-chat/_package.json @@ -0,0 +1,38 @@ +{ + "name": "__PACKAGE_NAME__", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "wrangler dev", + "deploy": "wrangler deploy", + "db:migrate": "wrangler d1 migrations apply __PACKAGE_NAME__ --remote", + "db:migrate:local": "wrangler d1 migrations apply __PACKAGE_NAME__ --local", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest" + }, + "dependencies": { + "@tangle-network/agent-app": "__AGENT_APP_VERSION__", + "@tangle-network/agent-interface": "^0.15.0", + "@tangle-network/agent-runtime": "^0.79.3", + "@tangle-network/sandbox": "^0.10.5", + "better-auth": "^1.6.16", + "drizzle-orm": "^0.45.2" + }, + "peerDependencies": { + "@tangle-network/agent-eval": ">=0.100.0", + "@tangle-network/agent-integrations": ">=0.44.0" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20250620.0", + "@tangle-network/agent-eval": "^0.100.0", + "@tangle-network/agent-integrations": "^0.44.0", + "@types/better-sqlite3": "^7.6.13", + "@types/node": "^25.6.0", + "better-sqlite3": "^12.10.0", + "typescript": "^5.7.0", + "vitest": "^3.0.0", + "wrangler": "^4.0.0" + } +} diff --git a/create-agent-app/template-chat/_tsconfig.json b/create-agent-app/template-chat/_tsconfig.json new file mode 100644 index 0000000..ab57f40 --- /dev/null +++ b/create-agent-app/template-chat/_tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2022"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "noUncheckedIndexedAccess": true, + "noEmit": true, + "types": ["@cloudflare/workers-types/2023-07-01", "node"] + }, + "include": ["agent.config.ts", "declarations.d.ts", "src", "tests", "vitest.config.ts"] +} diff --git a/create-agent-app/template-chat/_wrangler.toml b/create-agent-app/template-chat/_wrangler.toml new file mode 100644 index 0000000..c04e971 --- /dev/null +++ b/create-agent-app/template-chat/_wrangler.toml @@ -0,0 +1,38 @@ +name = "__PACKAGE_NAME__" +main = "src/worker.ts" +compatibility_date = "2025-01-01" +compatibility_flags = ["nodejs_compat"] + +# The dev chat page. Assets are served before the worker; /api/* falls +# through to src/worker.ts. +[assets] +directory = "public" + +# D1 holds everything durable: auth, threads/messages, the turn buffer. +# Setup: `wrangler d1 create __PACKAGE_NAME__`, paste the database_id, then +# `pnpm db:migrate:local` (dev) / `pnpm db:migrate` (production). +[[d1_databases]] +binding = "DB" +database_name = "__PACKAGE_NAME__" +database_id = "REPLACE_WITH_D1_DATABASE_ID" +migrations_dir = "migrations" + +# R2 is OFF by default — uncomment (and the ARTIFACTS binding in src/env.ts) +# only when the product needs artifact/object storage. +# [[r2_buckets]] +# binding = "ARTIFACTS" +# bucket_name = "__PACKAGE_NAME__-artifacts" + +# prompts/system.md is imported as a Text module (see declarations.d.ts; +# vitest mirrors this via the plugin in vitest.config.ts). +[[rules]] +type = "Text" +globs = ["**/*.md"] +fallthrough = true + +# Secrets live in .dev.vars locally and `wrangler secret put` in production: +# BETTER_AUTH_SECRET, TANGLE_API_KEY, SANDBOX_API_KEY, SANDBOX_GATEWAY_URL. +# See .dev.vars.example for the full list. +[vars] +BETTER_AUTH_URL = "http://localhost:8787" +# MODEL_NAME = "REPLACE_WITH_MODEL" # overrides config.model.default diff --git a/create-agent-app/template-chat/agent.config.ts b/create-agent-app/template-chat/agent.config.ts new file mode 100644 index 0000000..bfb1b80 --- /dev/null +++ b/create-agent-app/template-chat/agent.config.ts @@ -0,0 +1,62 @@ +/** + * agent.config.ts — the DATA surface of this chat product. + * + * This is the ONE file (plus `prompts/system.md`) you fill to define who the + * agent is and how its turns execute. It is plain data consumed by + * `@tangle-network/agent-app`'s modules through typed seams — NOT behavior. + * Control flow lives in `src/` (the assembled chat vertical). See CUSTOMIZE.md + * for the ordered fill-checklist and AGENTS.md for the layering contract. + * + * Every field below is stubbed with a placeholder. Replace the placeholders; + * keep the shape. `pnpm typecheck` proves the shape; `pnpm test` proves the + * wiring end to end (fake sandbox producer → turn → persisted transcript). + */ + +import type { Harness } from '@tangle-network/agent-app/harness' +// Imported as a Text module: wrangler's `[[rules]]` and the vitest plugin in +// `vitest.config.ts` both load `.md` files as strings (see declarations.d.ts). +import systemPrompt from './prompts/system.md' + +export const config = { + /** Product/agent name — cookie prefix, sandbox box names, email subjects. */ + name: '__PROJECT_NAME__', + + /** + * The system prompt, verbatim from `prompts/system.md`. State intents and + * hard rules, never implementations — the executing agent chooses its own + * tools at execution time (see AGENTS.md "prompts state intents"). + */ + systemPrompt, + + model: { + /** + * Model used when the client doesn't pick one and the `MODEL_NAME` env + * var is unset. Any model your Tangle Router key can reach. + */ + default: 'REPLACE_WITH_MODEL', + /** Default reasoning effort for turns that don't specify one. */ + effort: 'auto', + }, + + /** + * The agent harness the sandbox runs (`opencode`, `claude-code`, `codex`, + * …). Vendor-locked harnesses reject foreign-provider models — the sandbox + * lane enforces that pairing server-side. + */ + harness: 'opencode', + + /** + * Which sidecar ask kinds this app renders as cards. Anything the agent + * asks outside this set is auto-declined so a turn never hangs waiting on + * a card no client will show. + */ + interactions: { question: true, plan: true }, +} as const satisfies { + name: string + systemPrompt: string + model: { default: string; effort: 'auto' | 'low' | 'medium' | 'high' } + harness: Harness + interactions: { question?: boolean; permission?: boolean; plan?: boolean } +} + +export type Config = typeof config diff --git a/create-agent-app/template-chat/declarations.d.ts b/create-agent-app/template-chat/declarations.d.ts new file mode 100644 index 0000000..8e74721 --- /dev/null +++ b/create-agent-app/template-chat/declarations.d.ts @@ -0,0 +1,9 @@ +/** + * Text-module declarations. `prompts/system.md` is imported as a plain string + * by `agent.config.ts`; wrangler loads it via the `[[rules]]` Text rule in + * wrangler.toml and vitest via the inline plugin in vitest.config.ts. + */ +declare module '*.md' { + const text: string + export default text +} diff --git a/create-agent-app/template-chat/migrations/0001_init.sql b/create-agent-app/template-chat/migrations/0001_init.sql new file mode 100644 index 0000000..413a3b3 --- /dev/null +++ b/create-agent-app/template-chat/migrations/0001_init.sql @@ -0,0 +1,109 @@ +-- 0001_init.sql — the complete schema for the assembled chat vertical. +-- Apply with: pnpm db:migrate:local (dev) / pnpm db:migrate (production D1). +-- +-- Three blocks, each mirroring a factory the app composes. The e2e test in +-- tests/ executes THIS file against a real SQLite database and then drives +-- sign-up, thread CRUD, and a full streamed turn through it — if any block +-- drifts from the factory that owns it, the test fails before you ship. + +-- ── better-auth (src/db/schema.ts users/sessions/accounts/verifications) ──── + +CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + email TEXT NOT NULL UNIQUE, + email_verified INTEGER NOT NULL, + image TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, + expires_at INTEGER NOT NULL, + token TEXT NOT NULL UNIQUE, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + ip_address TEXT, + user_agent TEXT, + user_id TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS accounts ( + id TEXT PRIMARY KEY, + account_id TEXT NOT NULL, + provider_id TEXT NOT NULL, + user_id TEXT NOT NULL, + access_token TEXT, + refresh_token TEXT, + id_token TEXT, + access_token_expires_at INTEGER, + refresh_token_expires_at INTEGER, + scope TEXT, + password TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS verifications ( + id TEXT PRIMARY KEY, + identifier TEXT NOT NULL, + value TEXT NOT NULL, + expires_at INTEGER NOT NULL, + created_at INTEGER, + updated_at INTEGER +); + +-- ── chat threads/messages (createChatTables() in src/db/schema.ts) ────────── +-- workspace_id is a plain column: this template ships single-user workspaces +-- (workspace id = user id). Moving to real teams later adds the FK via +-- createChatTables({ workspaceTable }). + +CREATE TABLE IF NOT EXISTS thread ( + id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))), + workspace_id TEXT NOT NULL, + title TEXT NOT NULL, + category TEXT, + is_pinned INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL DEFAULT (unixepoch()), + updated_at INTEGER NOT NULL DEFAULT (unixepoch()) +); +CREATE INDEX IF NOT EXISTS idx_thread_workspace ON thread (workspace_id); +CREATE INDEX IF NOT EXISTS idx_thread_workspace_updated ON thread (workspace_id, updated_at); + +CREATE TABLE IF NOT EXISTS message ( + id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))), + thread_id TEXT NOT NULL REFERENCES thread(id) ON DELETE CASCADE, + role TEXT NOT NULL, + content TEXT NOT NULL, + parts TEXT DEFAULT '[]', + tool_name TEXT, + model TEXT, + input_tokens INTEGER, + output_tokens INTEGER, + reasoning_tokens INTEGER, + cache_read_tokens INTEGER, + cache_write_tokens INTEGER, + cost_usd REAL, + created_at INTEGER NOT NULL DEFAULT (unixepoch()) +); +CREATE INDEX IF NOT EXISTS idx_message_thread ON message (thread_id); +CREATE INDEX IF NOT EXISTS idx_message_thread_created ON message (thread_id, created_at); + +-- ── turn buffer (TURN_EVENTS_MIGRATION_SQL from @tangle-network/agent-app/stream) +-- Byte-for-byte the constant the D1 turn-event store expects; the e2e test +-- asserts this block matches the exported SQL so it cannot drift silently. + +CREATE TABLE IF NOT EXISTS turn_events ( + turnId TEXT NOT NULL, + seq INTEGER NOT NULL, + event TEXT NOT NULL, + PRIMARY KEY (turnId, seq) +); +CREATE TABLE IF NOT EXISTS turn_status ( + turnId TEXT PRIMARY KEY, + status TEXT NOT NULL, + scopeId TEXT, + updatedAt TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_turn_status_scope ON turn_status (scopeId, status); diff --git a/create-agent-app/template-chat/prompts/system.md b/create-agent-app/template-chat/prompts/system.md new file mode 100644 index 0000000..4b42825 --- /dev/null +++ b/create-agent-app/template-chat/prompts/system.md @@ -0,0 +1,11 @@ +# System prompt — replace this file with the real persona + +You are the __PROJECT_NAME__ assistant. +Rewrite this paragraph as the real one-paragraph role + voice + remit: whose job this agent does, in whose voice, under what hard rules. +It is the spine of every turn. + +Hard rules: + +- Never fabricate a figure (price, identifier, clause, date). Cite a real record or say NOT ON FILE. +- Route every regulated or client-facing action to a named human for approval; propose, don't execute. +- State what you did and what evidence backs it. No filler. diff --git a/create-agent-app/template-chat/public/index.html b/create-agent-app/template-chat/public/index.html new file mode 100644 index 0000000..e402ff6 --- /dev/null +++ b/create-agent-app/template-chat/public/index.html @@ -0,0 +1,236 @@ + + + + + +__PROJECT_NAME__ — dev chat + + + + + +
+
+ __PROJECT_NAME__ dev chat + +
+
+
+
+
+ + + + +
+
+ + +
+ Sign in + + + + + +
+
+ + + + diff --git a/create-agent-app/template-chat/src/chat.ts b/create-agent-app/template-chat/src/chat.ts new file mode 100644 index 0000000..8a55e34 --- /dev/null +++ b/create-agent-app/template-chat/src/chat.ts @@ -0,0 +1,203 @@ +/** + * src/chat.ts — the COMPOSER. The whole server chat vertical assembled from + * `@tangle-network/agent-app` factories, exactly the `examples/chat-app.md` + * assembly made runnable: + * + * auth `createAppAuth` (better-auth over drizzle/D1) + its guards + * persistence `createChatStore` over the tables in `src/db/schema.ts` + * turn `createChatTurnRoutes` — body validation, turn identity, + * the default turn-buffer tap (replay after a drop), user and + * assistant rows persisted with typed parts + usage receipt + * producer the sandbox lane from `src/sandbox.ts` + * uploads `createUploadRoute` — small files inline (`data:` URI), + * large files into the sandbox workspace by path + * asks `/interactions` list/answer endpoints over the sidecar + * + * You extend THIS file (and `src/worker.ts`); you never edit the shell. The + * `overrides` seams exist so the e2e test in `tests/` can run the identical + * assembly against an in-memory database and a fake sandbox producer — the + * production wiring is the zero-override call. + * + * Workspaces are single-user here (workspace id = user id). Multi-user teams + * later: `@tangle-network/agent-app/teams` + `createChatTables({ workspaceTable })`. + */ + +import { config } from '../agent.config' +import { createAppAuth, type AppAuth } from '@tangle-network/agent-app/app-auth' +import { + createChatTurnRoutes, + createUploadRoute, + type ChatTurnAuthorization, + type ChatTurnProduceArgs, + type ChatTurnRouteProducer, + type ChatTurnRoutes, + type SandboxUploadSink, +} from '@tangle-network/agent-app/chat-routes' +import { + createChatStore, + type ChatDatabase, + type ChatStore, +} from '@tangle-network/agent-app/chat-store' +import { guardResolution } from '@tangle-network/agent-app/platform' +import { + createD1TurnEventStore, + type TurnEventStore, +} from '@tangle-network/agent-app/stream' +import { drizzle } from 'drizzle-orm/d1' +import { accounts, messages, sessions, threads, users, verifications } from './db/schema' +import type { AppEnv } from './env' +import { appSlug, createSandboxProduce, resolveSidecarConnection, resolveUploadSink } from './sandbox' + +export interface ChatAppOverrides { + /** Test seam: an in-memory drizzle db (the e2e test runs the REAL + * `migrations/0001_init.sql` into better-sqlite3). Default: `drizzle(env.DB)`. */ + db?: ChatDatabase + /** Test seam: `createMemoryTurnEventStore()`. Default: D1-backed buffer. */ + turnStore?: TurnEventStore + /** Test seam: a fake sandbox producer. Default: the real sandbox lane. */ + produce?: (args: ChatTurnProduceArgs) => ChatTurnRouteProducer | Promise + /** Test seam: where large uploads land. Default: the workspace box's fs. */ + uploadSink?: (scope: { workspaceId: string; userId: string }) => Promise +} + +export interface ChatApp { + auth: AppAuth + store: ChatStore + routes: ChatTurnRoutes & { + /** POST `{ title?, firstMessage? }` → `{ thread }`. */ + createThread(request: Request): Promise + /** GET → `{ threads, total, limit, offset }`. */ + listThreads(request: Request): Promise + /** GET → `{ thread, messages }` — the full typed transcript. */ + threadMessages(request: Request, params: { threadId: string }): Promise + } + upload(request: Request): Promise +} + +const notFound = () => Response.json({ error: 'Thread not found' }, { status: 404 }) + +export function buildChatApp(env: AppEnv, overrides: ChatAppOverrides = {}): ChatApp { + const db = overrides.db ?? (drizzle(env.DB) as unknown as ChatDatabase) + const store = createChatStore(db, { threads, messages }) + + const auth = createAppAuth({ + appName: config.name, + baseURL: env.BETTER_AUTH_URL, + secret: env.BETTER_AUTH_SECRET, + db, + schema: { users, sessions, accounts, verifications }, + // For a throwaway prototype you can swap the drizzle pair for + // `database: memoryAdapter({ user: [], session: [], account: [], verification: [] })` + // (from 'better-auth/adapters/memory') — nothing survives a restart. + }) + + /** Session → identity + thread access, for both routes and seams. Guards + * throw JSON Responses; `guardResolution` adapts them to `{ ok, response }`. */ + async function requireUser(request: Request) { + return guardResolution(() => auth.requireApiUser(request)) + } + + /** The one product-supplied access step for turn + replay. Identity comes + * from the SESSION, never from the request body — a client cannot forge + * `userId`/`workspaceId`. */ + async function authorize(args: { + request: Request + intent: 'turn' | 'replay' + body?: Record + }): Promise> { + const session = await requireUser(args.request) + if (!session.ok) return session + const { user } = session.value + if (args.intent === 'turn') { + const threadId = String(args.body?.threadId ?? '') + const thread = await store.getThread(threadId) + // Inaccessible reads are indistinguishable from missing ones — a + // cross-workspace probe must not learn the thread exists. + if (!thread || thread.workspaceId !== user.id) return { ok: false, response: notFound() } + } + // Replay authorizes on session only: turn ids are unguessable UUIDs minted + // server-side and announced only on the owner's live stream. + return { ok: true, tenantId: user.id, userId: user.id, context: undefined } + } + + const routes = createChatTurnRoutes({ + projectId: appSlug, + authorize, + store, + turnStore: overrides.turnStore ?? createD1TurnEventStore(env.DB), + produce: overrides.produce ?? createSandboxProduce(env), + interactions: { + resolveConnection: async ({ request, intent, body }) => { + const session = await requireUser(request) + if (!session.ok) return session + const { user } = session.value + const threadId = + intent === 'answer' + ? String(body?.threadId ?? '') + : (new URL(request.url).searchParams.get('threadId') ?? '') + const thread = await store.getThread(threadId) + if (!thread || thread.workspaceId !== user.id) return { ok: false, response: notFound() } + const connection = await resolveSidecarConnection(env, { + workspaceId: user.id, + userId: user.id, + threadId, + }) + if (!connection) return { ok: false, unavailable: 'SANDBOX_UNAVAILABLE' } + return { ok: true, connection } + }, + }, + }) + + const upload = createUploadRoute({ + authorize: async ({ request }) => { + const session = await requireUser(request) + if (!session.ok) return session + const { user } = session.value + const resolveSink = overrides.uploadSink ?? ((scope) => resolveUploadSink(env, scope)) + return { ok: true, sink: await resolveSink({ workspaceId: user.id, userId: user.id }) } + }, + }) + + async function createThread(request: Request): Promise { + const session = await requireUser(request) + if (!session.ok) return session.response + const body = (await request.json().catch(() => null)) as + | { title?: string; firstMessage?: string } + | null + const thread = await store.createThread({ + workspaceId: session.value.user.id, + ...(body?.title ? { title: body.title } : {}), + ...(body?.firstMessage ? { firstMessage: body.firstMessage } : {}), + }) + return Response.json({ thread }) + } + + async function listThreads(request: Request): Promise { + const session = await requireUser(request) + if (!session.ok) return session.response + const url = new URL(request.url) + const limit = Number(url.searchParams.get('limit')) || undefined + const offset = Number(url.searchParams.get('offset')) || undefined + const result = await store.listThreads({ + workspaceId: session.value.user.id, + ...(limit !== undefined ? { limit } : {}), + ...(offset !== undefined ? { offset } : {}), + }) + return Response.json(result) + } + + async function threadMessages(request: Request, params: { threadId: string }): Promise { + const session = await requireUser(request) + if (!session.ok) return session.response + const thread = await store.getThread(params.threadId) + if (!thread || thread.workspaceId !== session.value.user.id) return notFound() + return Response.json({ thread, messages: await store.listMessages(params.threadId) }) + } + + return { + auth, + store, + routes: { ...routes, createThread, listThreads, threadMessages }, + upload, + } +} diff --git a/create-agent-app/template-chat/src/db/schema.ts b/create-agent-app/template-chat/src/db/schema.ts new file mode 100644 index 0000000..5ad033d --- /dev/null +++ b/create-agent-app/template-chat/src/db/schema.ts @@ -0,0 +1,70 @@ +/** + * src/db/schema.ts — the whole database graph, one drizzle schema. + * + * Two halves: + * - better-auth's users/sessions/accounts/verifications tables (the standard + * shape its drizzle adapter expects — column names must match + * `migrations/0001_init.sql`, which the e2e test executes for real). + * - the chat thread/message pair from `createChatTables()` (the shell owns + * the columns; you never hand-roll them). + * + * `workspace_id` on threads is a plain text column here: this template ships + * single-user workspaces (workspace = user id). Adopting real teams later + * means passing your workspace table as `createChatTables({ workspaceTable })` + * — see `@tangle-network/agent-app/teams`. + */ + +import { createChatTables } from '@tangle-network/agent-app/chat-store' +import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core' + +// ── better-auth tables ────────────────────────────────────────────────────── + +export const users = sqliteTable('users', { + id: text('id').primaryKey(), + name: text('name').notNull(), + email: text('email').notNull().unique(), + emailVerified: integer('email_verified', { mode: 'boolean' }).notNull(), + image: text('image'), + createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), + updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(), +}) + +export const sessions = sqliteTable('sessions', { + id: text('id').primaryKey(), + expiresAt: integer('expires_at', { mode: 'timestamp' }).notNull(), + token: text('token').notNull().unique(), + createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), + updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(), + ipAddress: text('ip_address'), + userAgent: text('user_agent'), + userId: text('user_id').notNull(), +}) + +export const accounts = sqliteTable('accounts', { + id: text('id').primaryKey(), + accountId: text('account_id').notNull(), + providerId: text('provider_id').notNull(), + userId: text('user_id').notNull(), + accessToken: text('access_token'), + refreshToken: text('refresh_token'), + idToken: text('id_token'), + accessTokenExpiresAt: integer('access_token_expires_at', { mode: 'timestamp' }), + refreshTokenExpiresAt: integer('refresh_token_expires_at', { mode: 'timestamp' }), + scope: text('scope'), + password: text('password'), + createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), + updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(), +}) + +export const verifications = sqliteTable('verifications', { + id: text('id').primaryKey(), + identifier: text('identifier').notNull(), + value: text('value').notNull(), + expiresAt: integer('expires_at', { mode: 'timestamp' }).notNull(), + createdAt: integer('created_at', { mode: 'timestamp' }), + updatedAt: integer('updated_at', { mode: 'timestamp' }), +}) + +// ── chat tables (shell-owned columns) ─────────────────────────────────────── + +export const { threads, messages } = createChatTables() diff --git a/create-agent-app/template-chat/src/env.ts b/create-agent-app/template-chat/src/env.ts new file mode 100644 index 0000000..c7cb69b --- /dev/null +++ b/create-agent-app/template-chat/src/env.ts @@ -0,0 +1,33 @@ +/** + * src/env.ts — the Cloudflare bindings + vars this worker reads. + * + * Locally these come from wrangler.toml `[vars]` + `.dev.vars` (secrets); in + * production from the dashboard / `wrangler secret put`. See + * `.dev.vars.example` for the full list with comments. + */ + +export interface AppEnv { + /** D1 database — run `migrations/` against it before first boot. */ + DB: D1Database + + /** Absolute origin better-auth serves from (e.g. http://localhost:8787). */ + BETTER_AUTH_URL: string + /** better-auth HMAC secret (secret; set in .dev.vars / `wrangler secret`). */ + BETTER_AUTH_SECRET: string + + /** Overrides `config.model.default` without a redeploy. */ + MODEL_NAME?: string + /** Tangle Router key the harness bills model calls against. */ + TANGLE_API_KEY?: string + /** Tangle Router base URL; omit for the platform default. */ + TANGLE_ROUTER_URL?: string + + /** Sandbox gateway credentials. Without them every turn fails loud with a + * clear error — there is no mock fallback. */ + SANDBOX_API_KEY?: string + SANDBOX_GATEWAY_URL?: string + + // Optional R2 bucket for product artifacts — OFF by default. Uncomment the + // `[[r2_buckets]]` block in wrangler.toml and this binding together. + // ARTIFACTS: R2Bucket +} diff --git a/create-agent-app/template-chat/src/sandbox.ts b/create-agent-app/template-chat/src/sandbox.ts new file mode 100644 index 0000000..f9a260d --- /dev/null +++ b/create-agent-app/template-chat/src/sandbox.ts @@ -0,0 +1,141 @@ +/** + * src/sandbox.ts — the config-driven sandbox lane. CODE, not data. + * + * Everything agent-shaped runs in a Tangle sandbox (a full agent harness: + * skills, tools, bash, MCP) reached through `@tangle-network/agent-app`'s + * sandbox helpers. This file turns `agent.config.ts` + env into the three + * seams the chat vertical needs: + * + * - `createSandboxProduce` — the turn producer (`createChatTurnRoutes`'s + * `produce` seam): resolve the workspace box, stream the prompt, bridge + * raw sidecar events through `createSandboxChatProducer`. + * - `resolveUploadSink` — where >inline-cap uploads land (`box.fs`). + * - `resolveSidecarConnection` — where interaction answers go. + * + * No mock fallback: without SANDBOX_API_KEY / SANDBOX_GATEWAY_URL the turn + * fails loud with a clear error instead of pretending to answer. + */ + +import { config } from '../agent.config' +import { + createSandboxChatProducer, + type ChatTurnProduceArgs, + type ChatTurnRouteProducer, + type SandboxUploadSink, +} from '@tangle-network/agent-app/chat-routes' +import type { SidecarInteractionsConnection } from '@tangle-network/agent-app/interactions' +import { + ensureWorkspaceSandbox, + streamSandboxPrompt, + type SandboxRuntimeConfig, +} from '@tangle-network/agent-app/sandbox' +import type { AppEnv } from './env' + +/** Lowercased, non-alphanumerics collapsed: box names + projectId. */ +export const appSlug = config.name + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + +/** + * The sandbox shell: how boxes are named, credentialed, and provisioned for + * this product. Extend here when you add per-workspace env, file mounts, or + * per-user key minting (see `resolveSandboxClientCredentials` in + * `@tangle-network/agent-app/sandbox` for the credential-policy helper). + */ +export function createSandboxShell(env: AppEnv): SandboxRuntimeConfig { + return { + credentials: () => { + const apiKey = env.SANDBOX_API_KEY?.trim() + const baseUrl = env.SANDBOX_GATEWAY_URL?.trim() + if (!apiKey || !baseUrl) return null + return { apiKey, baseUrl } + }, + name: (workspaceId) => `${appSlug}-${workspaceId}`.slice(0, 63), + metadata: (harness) => ({ app: appSlug, harness }), + connectedIntegrationIds: async () => [], + env: async () => ({}), + files: async () => [], + secrets: async () => [], + profile: ({ systemPrompt, extraMcp }) => ({ + name: appSlug, + prompt: { systemPrompt: systemPrompt ?? config.systemPrompt }, + ...(extraMcp && Object.keys(extraMcp).length > 0 ? { mcp: extraMcp } : {}), + }), + provider: { + ...(env.TANGLE_API_KEY ? { apiKey: env.TANGLE_API_KEY } : {}), + ...(env.TANGLE_ROUTER_URL ? { routerBaseUrl: env.TANGLE_ROUTER_URL } : {}), + ...(env.MODEL_NAME ? { modelName: env.MODEL_NAME } : {}), + defaultModel: config.model.default, + }, + } +} + +/** + * The `produce` seam for `createChatTurnRoutes`: one call per turn. The + * chat thread id doubles as the agent session id, so follow-up turns land in + * the same sidecar session and keep its context. + */ +export function createSandboxProduce(env: AppEnv) { + const shell = createSandboxShell(env) + return async ({ + body, + identity, + prompt, + executionId, + }: ChatTurnProduceArgs): Promise => { + const box = await ensureWorkspaceSandbox(shell, { + workspaceId: identity.tenantId, + userId: identity.userId, + harness: config.harness, + }) + const model = body.model ?? env.MODEL_NAME ?? config.model.default + return createSandboxChatProducer({ + model, + events: streamSandboxPrompt(shell, box, prompt, { + sessionId: identity.sessionId, + executionId, + model, + effort: body.effort ?? config.model.effort, + harness: config.harness, + systemPrompt: config.systemPrompt, + interactions: config.interactions, + }), + }) + } +} + +/** Where large uploads land: the workspace box's filesystem. Returns null when + * no sandbox is configured — the upload route then accepts inline files only + * and rejects oversized ones with an explicit 413. */ +export async function resolveUploadSink( + env: AppEnv, + scope: { workspaceId: string; userId: string }, +): Promise { + if (!env.SANDBOX_API_KEY?.trim() || !env.SANDBOX_GATEWAY_URL?.trim()) return null + const shell = createSandboxShell(env) + const box = await ensureWorkspaceSandbox(shell, { ...scope, harness: config.harness }) + return box.fs +} + +/** The sidecar connection interaction answers travel over. `sessionId` is the + * thread id — the same session the turn streams under. */ +export async function resolveSidecarConnection( + env: AppEnv, + scope: { workspaceId: string; userId: string; threadId: string }, +): Promise { + if (!env.SANDBOX_API_KEY?.trim() || !env.SANDBOX_GATEWAY_URL?.trim()) return null + const shell = createSandboxShell(env) + const box = await ensureWorkspaceSandbox(shell, { + workspaceId: scope.workspaceId, + userId: scope.userId, + harness: config.harness, + }) + const connection = box.connection + if (!connection?.runtimeUrl) return null + return { + runtimeUrl: connection.runtimeUrl, + ...(connection.authToken ? { authToken: connection.authToken } : {}), + sessionId: scope.threadId, + } +} diff --git a/create-agent-app/template-chat/src/worker.ts b/create-agent-app/template-chat/src/worker.ts new file mode 100644 index 0000000..3dccdf2 --- /dev/null +++ b/create-agent-app/template-chat/src/worker.ts @@ -0,0 +1,54 @@ +/** + * src/worker.ts — the HTTP surface. Routing only; every handler is a factory + * product from `src/chat.ts`. Static assets (the dev chat page in `public/`) + * are served by the Workers assets pipeline before this fetch handler runs. + * + * Route map: + * ALL /api/auth/* better-auth (sign-up/sign-in/session) + * POST /api/threads create a thread + * GET /api/threads list threads + * GET /api/threads/:id/messages typed transcript (parts + usage) + * POST /api/chat run one turn (NDJSON stream) + * GET /api/chat/replay/:turnId replay a buffered turn (?fromSeq=) + * POST /api/chat/upload multipart upload → prompt parts + * GET /api/chat/interactions outstanding agent asks (?threadId=) + * POST /api/chat/interactions answer an ask + */ + +import { buildChatApp } from './chat' +import type { AppEnv } from './env' + +export default { + async fetch(request: Request, env: AppEnv, ctx: ExecutionContext): Promise { + // Per-request assembly is the standard Workers pattern: env only exists + // inside fetch, and the factories are cheap closures over it. + const app = buildChatApp(env) + const url = new URL(request.url) + const { pathname } = url + const method = request.method + + if (pathname.startsWith('/api/auth/')) return app.auth.auth.handler(request) + + if (pathname === '/api/chat' && method === 'POST') { + // Pass waitUntil so the turn keeps running (and buffering for replay) + // after a client disconnect. + return app.routes.turn(request, ctx) + } + const replay = pathname.match(/^\/api\/chat\/replay\/([^/]+)$/) + if (replay && method === 'GET') return app.routes.replay(request, { turnId: replay[1]! }) + if (pathname === '/api/chat/upload' && method === 'POST') return app.upload(request) + if (pathname === '/api/chat/interactions' && app.routes.interactions) { + if (method === 'GET') return app.routes.interactions.list(request) + if (method === 'POST') return app.routes.interactions.answer(request) + } + + if (pathname === '/api/threads' && method === 'POST') return app.routes.createThread(request) + if (pathname === '/api/threads' && method === 'GET') return app.routes.listThreads(request) + const transcript = pathname.match(/^\/api\/threads\/([^/]+)\/messages$/) + if (transcript && method === 'GET') { + return app.routes.threadMessages(request, { threadId: transcript[1]! }) + } + + return Response.json({ error: 'Not found' }, { status: 404 }) + }, +} satisfies ExportedHandler diff --git a/create-agent-app/template-chat/tests/chat-turn.e2e.test.ts b/create-agent-app/template-chat/tests/chat-turn.e2e.test.ts new file mode 100644 index 0000000..c5e59ac --- /dev/null +++ b/create-agent-app/template-chat/tests/chat-turn.e2e.test.ts @@ -0,0 +1,254 @@ +/** + * The e2e gate this app ships with: the REAL assembly (`buildChatApp` — auth, + * store, turn routes, upload, replay) driven end to end against the REAL + * migration, with exactly one fake at the outermost seam — the sandbox event + * feed. `createSandboxChatProducer` (the real bridge) consumes canonical + * sidecar events a live box would emit, so everything below the fake is + * production code: + * + * sign-up (better-auth drizzle adapter over the migrated tables) + * → create thread → upload a file (inline `data:` part) + * → POST /api/chat with content + parts → consume the NDJSON stream + * → user + assistant rows persisted with typed parts + usage receipt + * → replay the buffered turn after the live stream is gone. + * + * If this file fails after an edit, the app has drifted from the framework + * contract (or the migration from the schema). Fix the drift, not the test. + */ + +import { readFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import Database from 'better-sqlite3' +import { drizzle } from 'drizzle-orm/better-sqlite3' +import { describe, expect, it } from 'vitest' + +import { + createSandboxChatProducer, + type ChatTurnRouteProducer, +} from '@tangle-network/agent-app/chat-routes' +import type { ChatDatabase } from '@tangle-network/agent-app/chat-store' +import { + createMemoryTurnEventStore, + TURN_EVENTS_MIGRATION_SQL, +} from '@tangle-network/agent-app/stream' + +import { config } from '../agent.config' +import { buildChatApp, type ChatApp } from '../src/chat' +import type { AppEnv } from '../src/env' + +const BASE = 'http://localhost:8787' +const MODEL = 'test/model-1' + +// ── fixtures ──────────────────────────────────────────────────────────────── + +const MIGRATION = join(dirname(fileURLToPath(import.meta.url)), '..', 'migrations', '0001_init.sql') + +/** The real migration, executed against a real SQLite database. Every query + * the test makes afterwards runs over THESE tables — schema drift between + * `migrations/` and `src/db/schema.ts` fails here, not in production. */ +function openMigratedDb(): ChatDatabase { + const sqlite = new Database(':memory:') + sqlite.pragma('foreign_keys = ON') + sqlite.exec(readFileSync(MIGRATION, 'utf8')) + // better-sqlite3's sync drizzle handle narrows the driver generic; the store + // treats sync and async drivers identically (builders are awaited). + return drizzle(sqlite) as unknown as ChatDatabase +} + +/** Raw sidecar events, exactly as `streamSandboxPrompt` would yield them from + * a live box: reasoning + text deltas, a tool round-trip, the usage receipt, + * and the final-text result. */ +const RAW_TURN_EVENTS: Array> = [ + { type: 'message.part.updated', data: { part: { type: 'reasoning', id: 'r1', text: 'checking the records' }, delta: 'checking the records' } }, + { type: 'message.part.updated', data: { part: { type: 'text', id: 't1', text: 'Filed ' }, delta: 'Filed ' } }, + { type: 'message.part.updated', data: { part: { type: 'tool', id: 'call-1', tool: 'record_search', state: { status: 'running', input: { query: 'lease' } } } } }, + { type: 'message.part.updated', data: { part: { type: 'tool', id: 'call-1', tool: 'record_search', state: { status: 'completed', input: { query: 'lease' }, output: { hits: 2 } } } } }, + { type: 'message.part.updated', data: { part: { type: 'text', id: 't1', text: 'Filed the summary.' }, delta: 'the summary.' } }, + { type: 'message.part.updated', data: { part: { type: 'step-finish', reason: 'stop', tokens: { input: 40, output: 20, reasoning: 5, cache: { read: 10, write: 2 } }, cost: 0.0123 } } }, + { type: 'result', data: { finalText: 'Filed the summary.' } }, +] + +async function* feed(events: Array>): AsyncGenerator { + for (const event of events) yield event +} + +const env: AppEnv = { + // The DB binding is unused when the test injects its own drizzle handle. + DB: null as unknown as AppEnv['DB'], + BETTER_AUTH_URL: BASE, + BETTER_AUTH_SECRET: 'e2e-test-secret-not-for-production', +} + +interface Harness { + app: ChatApp + cookie: string + settle(): Promise +} + +async function createHarness( + produce: () => ChatTurnRouteProducer = () => + createSandboxChatProducer({ events: feed(RAW_TURN_EVENTS), model: MODEL }), +): Promise { + const app = buildChatApp(env, { + db: openMigratedDb(), + turnStore: createMemoryTurnEventStore(), + produce, + uploadSink: async () => null, // inline uploads only; no box in tests + }) + // Real sign-up through better-auth; the returned cookie is what a browser + // would replay on every API call. + const res = await app.auth.auth.handler( + new Request(`${BASE}/api/auth/sign-up/email`, { + method: 'POST', + headers: { 'content-type': 'application/json', origin: BASE }, + body: JSON.stringify({ email: 'e2e@example.com', password: 'correct-horse-battery', name: 'e2e' }), + }), + ) + expect(res.status).toBe(200) + const cookie = res.headers + .getSetCookie() + .map((c) => c.split(';')[0]!) + .join('; ') + + const pending: Promise[] = [] + const originalTurn = app.routes.turn + app.routes.turn = (request) => + originalTurn(request, { waitUntil: (p) => void pending.push(p) }) + return { app, cookie, settle: () => Promise.all(pending) } +} + +function post(path: string, cookie: string, body: unknown): Request { + return new Request(`${BASE}${path}`, { + method: 'POST', + headers: { 'content-type': 'application/json', cookie }, + body: JSON.stringify(body), + }) +} + +async function readLines(res: Response): Promise>> { + const text = await new Response(res.body).text() + return text + .split('\n') + .filter(Boolean) + .map((line) => JSON.parse(line) as Record) +} + +/** Flatten the NDJSON line vocabulary (`{kind:'event', event}` wrappers) the + * same way web-react's `dispatchChatStreamLine` does. */ +function eventsOf(lines: Array>): Array> { + return lines.map((l) => (l.kind === 'event' ? (l.event as Record) : l)) +} + +// ── the gate ──────────────────────────────────────────────────────────────── + +describe('e2e: fake sandbox producer → streamed turn → persisted transcript', () => { + it('runs the full multimodal vertical: upload, turn, stream, rows, replay', async () => { + const { app, cookie, settle } = await createHarness() + + // Thread + const threadRes = await app.routes.createThread( + post('/api/threads', cookie, { firstMessage: 'File my lease summary' }), + ) + expect(threadRes.status).toBe(200) + const { thread } = (await threadRes.json()) as { thread: { id: string } } + + // Upload → inline `data:` part (≤700 KiB stays in the turn body) + const form = new FormData() + form.append('files', new File(['%PDF-1.4 fake'], 'lease.pdf', { type: 'application/pdf' })) + const uploadRes = await app.upload( + new Request(`${BASE}/api/chat/upload`, { method: 'POST', headers: { cookie }, body: form }), + ) + expect(uploadRes.status).toBe(200) + const { files } = (await uploadRes.json()) as { + files: Array<{ inline: boolean; part: Record }> + } + expect(files[0]!.inline).toBe(true) + expect(String(files[0]!.part.url)).toMatch(/^data:application\/pdf;base64,/) + + // Turn: content + the uploaded part, streamed as NDJSON + const turnRes = await app.routes.turn( + post('/api/chat', cookie, { + threadId: thread.id, + content: 'File my lease summary', + parts: [files[0]!.part], + }), + ) + expect(turnRes.status).toBe(200) + const lines = await readLines(turnRes) + const events = eventsOf(lines) + + // The stream announced the replay handle first, then the client vocabulary. + const turnId = String(lines[0]!.turnId ?? '') + expect(lines[0]).toMatchObject({ type: 'turn' }) + expect(turnId).toBeTruthy() + expect( + events.filter((e) => e.type === 'text').map((e) => String(e.text)).join(''), + ).toBe('Filed the summary.') + expect(events.some((e) => e.type === 'reasoning')).toBe(true) + const toolCall = events.find((e) => e.type === 'tool_call') as + | { call?: { toolName?: string } } + | undefined + expect(toolCall?.call?.toolName).toBe('record_search') + expect(events).toContainEqual( + expect.objectContaining({ type: 'usage', usage: { promptTokens: 40, completionTokens: 20 } }), + ) + await settle() + + // A later page load reads back both rows with typed parts + the receipt. + const transcriptRes = await app.routes.threadMessages( + new Request(`${BASE}/api/threads/${thread.id}/messages`, { headers: { cookie } }), + { threadId: thread.id }, + ) + const { messages } = (await transcriptRes.json()) as { + messages: Array & { parts?: Array> }> + } + expect(messages.map((m) => m.role)).toEqual(['user', 'assistant']) + + const user = messages[0]! + expect(user.parts?.some((p) => p.type === 'file' && p.filename === 'lease.pdf')).toBe(true) + + const assistant = messages[1]! + expect(assistant.content).toBe('Filed the summary.') + expect(assistant.model).toBe(MODEL) + expect(assistant.inputTokens).toBe(40) + expect(assistant.outputTokens).toBe(20) + expect(assistant.reasoningTokens).toBe(5) + expect(assistant.costUsd).toBeCloseTo(0.0123) + expect(assistant.parts?.some((p) => p.type === 'reasoning')).toBe(true) + const tool = assistant.parts?.find((p) => p.type === 'tool') + expect(tool).toMatchObject({ tool: 'record_search', state: { status: 'completed' } }) + expect(assistant.parts?.some((p) => p.type === 'step-finish')).toBe(true) + + // The buffered turn replays in full after the live stream is long gone. + const replayRes = await app.routes.replay( + new Request(`${BASE}/api/chat/replay/${turnId}?fromSeq=0`, { headers: { cookie } }), + { turnId }, + ) + const replayEvents = eventsOf(await readLines(replayRes)) + expect( + replayEvents.filter((e) => e.type === 'text').map((e) => String(e.text)).join(''), + ).toBe('Filed the summary.') + expect(replayEvents.at(-1)).toMatchObject({ type: 'turn_status', status: 'complete' }) + }) + + it('rejects an unauthenticated turn with the guard 401, before any row is written', async () => { + const { app, cookie } = await createHarness() + const threadRes = await app.routes.createThread(post('/api/threads', cookie, { firstMessage: 'seed' })) + const { thread } = (await threadRes.json()) as { thread: { id: string } } + + const res = await app.routes.turn(post('/api/chat', '', { threadId: thread.id, content: 'hi' })) + expect(res.status).toBe(401) + expect(await app.store.listMessages(thread.id)).toEqual([]) + }) + + it('the migration carries the turn-buffer DDL the /stream store expects, verbatim', () => { + const normalize = (sql: string) => sql.replace(/\s+/g, ' ').trim() + expect(normalize(readFileSync(MIGRATION, 'utf8'))).toContain(normalize(TURN_EVENTS_MIGRATION_SQL)) + }) + + it('agent.config carries a real system prompt (prompts/system.md is wired)', () => { + expect(config.systemPrompt.length).toBeGreaterThan(0) + expect(config.name.length).toBeGreaterThan(0) + }) +}) diff --git a/create-agent-app/template-chat/vitest.config.ts b/create-agent-app/template-chat/vitest.config.ts new file mode 100644 index 0000000..0540791 --- /dev/null +++ b/create-agent-app/template-chat/vitest.config.ts @@ -0,0 +1,22 @@ +import { readFileSync } from 'node:fs' +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + plugins: [ + { + // Mirror wrangler's `[[rules]]` Text modules: `.md` imports (the system + // prompt) resolve to plain strings under vitest too. + name: 'text-markdown', + enforce: 'pre', + load(id) { + if (id.endsWith('.md')) { + return `export default ${JSON.stringify(readFileSync(id, 'utf8'))}` + } + return null + }, + }, + ], + test: { + include: ['tests/**/*.test.ts'], + }, +}) diff --git a/knip.json b/knip.json index 1dfb231..feee7d7 100644 --- a/knip.json +++ b/knip.json @@ -48,6 +48,7 @@ "ignore": [ ".worktrees/**", "create-agent-app/template/**", + "create-agent-app/template-chat/**", "playground/**" ], "ignoreBinaries": ["tsc"] diff --git a/package.json b/package.json index b613441..c8d83b4 100644 --- a/package.json +++ b/package.json @@ -385,6 +385,7 @@ "knip": "knip" }, "devDependencies": { + "@cloudflare/workers-types": "^4.20250620.0", "@radix-ui/react-dialog": "^1.1.15", "@tangle-network/agent-eval": "^0.100.0", "@tangle-network/agent-integrations": "^0.44.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 80575dd..8a7a98f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,6 +12,9 @@ importers: specifier: ^4.4.3 version: 4.4.3 devDependencies: + '@cloudflare/workers-types': + specifier: ^4.20250620.0 + version: 4.20260702.1 '@radix-ui/react-dialog': specifier: ^1.1.15 version: 1.1.17(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -62,13 +65,13 @@ importers: version: 12.11.1(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) better-auth: specifier: ^1.6.16 - version: 1.6.16(@opentelemetry/api@1.9.1)(better-sqlite3@12.10.0)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(better-sqlite3@12.10.0)(kysely@0.29.2))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@3.2.6(@types/debug@4.1.13)(@types/node@25.9.1)(jiti@2.7.0)(jsdom@29.1.1(@noble/hashes@2.2.0)(canvas@3.2.3))(yaml@2.9.0)) + version: 1.6.16(@cloudflare/workers-types@4.20260702.1)(@opentelemetry/api@1.9.1)(better-sqlite3@12.10.0)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260702.1)(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(better-sqlite3@12.10.0)(kysely@0.29.2))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@3.2.6(@types/debug@4.1.13)(@types/node@25.9.1)(jiti@2.7.0)(jsdom@29.1.1(@noble/hashes@2.2.0)(canvas@3.2.3))(yaml@2.9.0)) better-sqlite3: specifier: ^12.10.0 version: 12.10.0 drizzle-orm: specifier: ^0.45.2 - version: 0.45.2(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(better-sqlite3@12.10.0)(kysely@0.29.2) + version: 0.45.2(@cloudflare/workers-types@4.20260702.1)(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(better-sqlite3@12.10.0)(kysely@0.29.2) fast-check: specifier: ^3.23.2 version: 3.23.2 @@ -238,6 +241,9 @@ packages: resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} hasBin: true + '@cloudflare/workers-types@4.20260702.1': + resolution: {integrity: sha512-mOhf5TUEB1m2vPrxtqoIGfz0fUC9xyxRDx5gWHy5s+OCo6dcV+g7wI1R7gYCMFohhqF/2y2xeKVwMwCJjfn/WA==} + '@csstools/color-helpers@6.0.2': resolution: {integrity: sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==} engines: {node: '>=20.19.0'} @@ -3283,7 +3289,7 @@ snapshots: '@babel/runtime@7.29.7': {} - '@better-auth/core@1.6.16(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.2.2)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0)': + '@better-auth/core@1.6.16(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.2.2)(@cloudflare/workers-types@4.20260702.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0)': dependencies: '@better-auth/utils': 0.4.1 '@better-fetch/fetch': 1.2.2 @@ -3295,40 +3301,41 @@ snapshots: nanostores: 1.3.0 zod: 4.4.3 optionalDependencies: + '@cloudflare/workers-types': 4.20260702.1 '@opentelemetry/api': 1.9.1 - '@better-auth/drizzle-adapter@1.6.16(@better-auth/core@1.6.16(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.2.2)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.1)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(better-sqlite3@12.10.0)(kysely@0.29.2))': + '@better-auth/drizzle-adapter@1.6.16(@better-auth/core@1.6.16(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.2.2)(@cloudflare/workers-types@4.20260702.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.1)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260702.1)(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(better-sqlite3@12.10.0)(kysely@0.29.2))': dependencies: - '@better-auth/core': 1.6.16(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.2.2)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0) + '@better-auth/core': 1.6.16(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.2.2)(@cloudflare/workers-types@4.20260702.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0) '@better-auth/utils': 0.4.1 optionalDependencies: - drizzle-orm: 0.45.2(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(better-sqlite3@12.10.0)(kysely@0.29.2) + drizzle-orm: 0.45.2(@cloudflare/workers-types@4.20260702.1)(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(better-sqlite3@12.10.0)(kysely@0.29.2) - '@better-auth/kysely-adapter@1.6.16(@better-auth/core@1.6.16(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.2.2)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.1)(kysely@0.29.2)': + '@better-auth/kysely-adapter@1.6.16(@better-auth/core@1.6.16(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.2.2)(@cloudflare/workers-types@4.20260702.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.1)(kysely@0.29.2)': dependencies: - '@better-auth/core': 1.6.16(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.2.2)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0) + '@better-auth/core': 1.6.16(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.2.2)(@cloudflare/workers-types@4.20260702.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0) '@better-auth/utils': 0.4.1 optionalDependencies: kysely: 0.29.2 - '@better-auth/memory-adapter@1.6.16(@better-auth/core@1.6.16(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.2.2)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.1)': + '@better-auth/memory-adapter@1.6.16(@better-auth/core@1.6.16(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.2.2)(@cloudflare/workers-types@4.20260702.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.1)': dependencies: - '@better-auth/core': 1.6.16(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.2.2)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0) + '@better-auth/core': 1.6.16(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.2.2)(@cloudflare/workers-types@4.20260702.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0) '@better-auth/utils': 0.4.1 - '@better-auth/mongo-adapter@1.6.16(@better-auth/core@1.6.16(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.2.2)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.1)': + '@better-auth/mongo-adapter@1.6.16(@better-auth/core@1.6.16(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.2.2)(@cloudflare/workers-types@4.20260702.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.1)': dependencies: - '@better-auth/core': 1.6.16(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.2.2)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0) + '@better-auth/core': 1.6.16(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.2.2)(@cloudflare/workers-types@4.20260702.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0) '@better-auth/utils': 0.4.1 - '@better-auth/prisma-adapter@1.6.16(@better-auth/core@1.6.16(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.2.2)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.1)': + '@better-auth/prisma-adapter@1.6.16(@better-auth/core@1.6.16(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.2.2)(@cloudflare/workers-types@4.20260702.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.1)': dependencies: - '@better-auth/core': 1.6.16(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.2.2)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0) + '@better-auth/core': 1.6.16(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.2.2)(@cloudflare/workers-types@4.20260702.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0) '@better-auth/utils': 0.4.1 - '@better-auth/telemetry@1.6.16(@better-auth/core@1.6.16(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.2.2)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.2.2)': + '@better-auth/telemetry@1.6.16(@better-auth/core@1.6.16(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.2.2)(@cloudflare/workers-types@4.20260702.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.2.2)': dependencies: - '@better-auth/core': 1.6.16(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.2.2)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0) + '@better-auth/core': 1.6.16(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.2.2)(@cloudflare/workers-types@4.20260702.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0) '@better-auth/utils': 0.4.1 '@better-fetch/fetch': 1.2.2 @@ -3342,6 +3349,8 @@ snapshots: dependencies: css-tree: 3.2.1 + '@cloudflare/workers-types@4.20260702.1': {} + '@csstools/color-helpers@6.0.2': {} '@csstools/css-calc@3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': @@ -4558,15 +4567,15 @@ snapshots: base64-js@1.5.1: {} - better-auth@1.6.16(@opentelemetry/api@1.9.1)(better-sqlite3@12.10.0)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(better-sqlite3@12.10.0)(kysely@0.29.2))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@3.2.6(@types/debug@4.1.13)(@types/node@25.9.1)(jiti@2.7.0)(jsdom@29.1.1(@noble/hashes@2.2.0)(canvas@3.2.3))(yaml@2.9.0)): + better-auth@1.6.16(@cloudflare/workers-types@4.20260702.1)(@opentelemetry/api@1.9.1)(better-sqlite3@12.10.0)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260702.1)(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(better-sqlite3@12.10.0)(kysely@0.29.2))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@3.2.6(@types/debug@4.1.13)(@types/node@25.9.1)(jiti@2.7.0)(jsdom@29.1.1(@noble/hashes@2.2.0)(canvas@3.2.3))(yaml@2.9.0)): dependencies: - '@better-auth/core': 1.6.16(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.2.2)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0) - '@better-auth/drizzle-adapter': 1.6.16(@better-auth/core@1.6.16(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.2.2)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.1)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(better-sqlite3@12.10.0)(kysely@0.29.2)) - '@better-auth/kysely-adapter': 1.6.16(@better-auth/core@1.6.16(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.2.2)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.1)(kysely@0.29.2) - '@better-auth/memory-adapter': 1.6.16(@better-auth/core@1.6.16(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.2.2)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.1) - '@better-auth/mongo-adapter': 1.6.16(@better-auth/core@1.6.16(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.2.2)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.1) - '@better-auth/prisma-adapter': 1.6.16(@better-auth/core@1.6.16(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.2.2)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.1) - '@better-auth/telemetry': 1.6.16(@better-auth/core@1.6.16(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.2.2)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.2.2) + '@better-auth/core': 1.6.16(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.2.2)(@cloudflare/workers-types@4.20260702.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0) + '@better-auth/drizzle-adapter': 1.6.16(@better-auth/core@1.6.16(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.2.2)(@cloudflare/workers-types@4.20260702.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.1)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260702.1)(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(better-sqlite3@12.10.0)(kysely@0.29.2)) + '@better-auth/kysely-adapter': 1.6.16(@better-auth/core@1.6.16(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.2.2)(@cloudflare/workers-types@4.20260702.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.1)(kysely@0.29.2) + '@better-auth/memory-adapter': 1.6.16(@better-auth/core@1.6.16(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.2.2)(@cloudflare/workers-types@4.20260702.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.1) + '@better-auth/mongo-adapter': 1.6.16(@better-auth/core@1.6.16(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.2.2)(@cloudflare/workers-types@4.20260702.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.1) + '@better-auth/prisma-adapter': 1.6.16(@better-auth/core@1.6.16(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.2.2)(@cloudflare/workers-types@4.20260702.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.1) + '@better-auth/telemetry': 1.6.16(@better-auth/core@1.6.16(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.2.2)(@cloudflare/workers-types@4.20260702.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.2.2) '@better-auth/utils': 0.4.1 '@better-fetch/fetch': 1.2.2 '@noble/ciphers': 2.2.0 @@ -4579,7 +4588,7 @@ snapshots: zod: 4.4.3 optionalDependencies: better-sqlite3: 12.10.0 - drizzle-orm: 0.45.2(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(better-sqlite3@12.10.0)(kysely@0.29.2) + drizzle-orm: 0.45.2(@cloudflare/workers-types@4.20260702.1)(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(better-sqlite3@12.10.0)(kysely@0.29.2) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) vitest: 3.2.6(@types/debug@4.1.13)(@types/node@25.9.1)(jiti@2.7.0)(jsdom@29.1.1(@noble/hashes@2.2.0)(canvas@3.2.3))(yaml@2.9.0) @@ -4771,8 +4780,9 @@ snapshots: dom-accessibility-api@0.5.16: {} - drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(better-sqlite3@12.10.0)(kysely@0.29.2): + drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260702.1)(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(better-sqlite3@12.10.0)(kysely@0.29.2): optionalDependencies: + '@cloudflare/workers-types': 4.20260702.1 '@opentelemetry/api': 1.9.1 '@types/better-sqlite3': 7.6.13 better-sqlite3: 12.10.0 diff --git a/tests/create-agent-app-chat.test.ts b/tests/create-agent-app-chat.test.ts new file mode 100644 index 0000000..f6074cb --- /dev/null +++ b/tests/create-agent-app-chat.test.ts @@ -0,0 +1,233 @@ +/** + * The `--chat` scaffold gate — the CI enforcement of the one-day-chat-app + * claim (#188 wave 3). It scaffolds the chat variant into a tmp dir, installs + * agent-app as a COPY of the published payload (package.json + dist — no + * repo-root symlink, so an undeclared engine dep cannot resolve through this + * repo's node_modules), links exactly the dependencies the generated + * package.json declares, then: + * + * 1. typechecks the generated app with its own tsconfig against the real + * published types, and + * 2. runs the generated app's OWN vitest suite — which contains the + * end-to-end scenario (fake sandbox producer → POST turn → NDJSON stream + * consumed → user+assistant rows persisted with typed parts → buffered + * replay) over the REAL migration SQL. + * + * If any shell factory the template composes changes shape, this test reds + * before the drift ships. + */ + +import { describe, it, expect, beforeAll } from 'vitest' +import { execFileSync } from 'node:child_process' +import { cpSync, mkdtempSync, existsSync, mkdirSync, symlinkSync, readFileSync, realpathSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' + +const REPO = resolve(__dirname, '..') +const CLI = join(REPO, 'create-agent-app', 'index.mjs') +const DIST = join(REPO, 'dist') + +function link(dest: string, src: string, required = true) { + if (!existsSync(src)) { + if (required) throw new Error(`chat template needs ${src} but this repo has no installed copy to link`) + return + } + mkdirSync(join(dest, '..'), { recursive: true }) + // Resolve through pnpm's symlink farm to the package's real on-disk location + // so the linked package's OWN dependencies (its `.pnpm` peer graph) resolve. + symlinkSync(realpathSync(src), dest, 'dir') +} + +/** Offline stand-in for `pnpm install`, hardened the same way as the base + * scaffold suite: agent-app is a payload COPY (its dist resolves siblings + * through the PROJECT's node_modules, like a registry install), and only + * packages the generated package.json declares get linked. */ +function linkDeps(projectDir: string) { + const nm = join(projectDir, 'node_modules') + const scope = join(nm, '@tangle-network') + mkdirSync(scope, { recursive: true }) + + const appDir = join(scope, 'agent-app') + mkdirSync(join(appDir, 'dist'), { recursive: true }) + cpSync(join(REPO, 'package.json'), join(appDir, 'package.json')) + cpSync(DIST, join(appDir, 'dist'), { recursive: true }) + + const pkg = JSON.parse(readFileSync(join(projectDir, 'package.json'), 'utf8')) as { + dependencies?: Record + devDependencies?: Record + peerDependencies?: Record + } + const declared = [pkg.dependencies, pkg.devDependencies, pkg.peerDependencies] + .flatMap((deps) => Object.keys(deps ?? {})) + .filter((name) => name !== '@tangle-network/agent-app') + + // Wrangler is a declared devDependency the tests never execute; linking the + // whole CLI tree buys nothing. Everything else the template declares must be + // linkable from this repo — a declared dep the repo cannot provide is drift. + const skip = new Set(['wrangler']) + for (const name of new Set(declared)) { + if (skip.has(name)) continue + link(join(nm, name), join(REPO, 'node_modules', name)) + } + // agent-app's own runtime dependency (zod) plus the sandbox SDK's peer graph + // resolve through the copied payload's parent — the project's node_modules — + // exactly as they would after a real install. + link(join(nm, 'zod'), join(REPO, 'node_modules', 'zod')) +} + +// "1.2.3" (after stripping a `^`/`>=` prefix) → comparable numeric tuple. +function minVersion(range: string): number[] { + return range.replace(/^[~^>=\s]+/, '').split('.').map(Number) +} + +function versionGte(a: number[], b: number[]): boolean { + for (let i = 0; i < Math.max(a.length, b.length); i++) { + const x = a[i] ?? 0 + const y = b[i] ?? 0 + if (x !== y) return x > y + } + return true +} + +describe('create-agent-app --chat scaffolder', () => { + let projectDir: string + + beforeAll(() => { + if (!existsSync(join(DIST, 'chat-routes', 'index.d.ts'))) { + throw new Error('dist/ not built — run `pnpm build` before this test') + } + const tmp = mkdtempSync(join(tmpdir(), 'create-agent-app-chat-')) + projectDir = join(tmp, 'demo-chat') + execFileSync('node', [CLI, projectDir, '--name', 'demo-chat', '--chat'], { stdio: 'pipe' }) + linkDeps(projectDir) + }) + + it('emits the chat vertical: config, composer, sandbox lane, migration, dev page, its own e2e test', () => { + const expected = [ + 'agent.config.ts', + 'prompts/system.md', + 'declarations.d.ts', + 'src/chat.ts', + 'src/sandbox.ts', + 'src/worker.ts', + 'src/env.ts', + 'src/db/schema.ts', + 'migrations/0001_init.sql', + 'public/index.html', + 'tests/chat-turn.e2e.test.ts', + 'package.json', + 'tsconfig.json', + 'vitest.config.ts', + 'wrangler.toml', + '.dev.vars.example', + 'AGENTS.md', + 'CLAUDE.md', + 'CUSTOMIZE.md', + 'README.md', + '.gitignore', + ] + for (const f of expected) { + expect(existsSync(join(projectDir, f)), `missing ${f}`).toBe(true) + } + }) + + it('substitutes tokens across package.json, agent.config.ts, wrangler.toml, and the dev page', () => { + const pkg = JSON.parse(readFileSync(join(projectDir, 'package.json'), 'utf8')) + expect(pkg.name).toBe('demo-chat') + expect(pkg.dependencies['@tangle-network/agent-app']).toBeTruthy() + const cfg = readFileSync(join(projectDir, 'agent.config.ts'), 'utf8') + expect(cfg).toContain("name: 'demo-chat'") + for (const file of ['agent.config.ts', 'wrangler.toml', 'public/index.html', 'prompts/system.md']) { + expect(readFileSync(join(projectDir, file), 'utf8'), `unsubstituted token in ${file}`).not.toMatch(/__[A-Z_]+__/) + } + expect(JSON.stringify(pkg)).not.toMatch(/__[A-Z_]+__/) + const wrangler = readFileSync(join(projectDir, 'wrangler.toml'), 'utf8') + expect(wrangler).toContain('migrations_dir = "migrations"') + }) + + it('template engine pins satisfy agent-app peerDependencies (drift gate)', () => { + const appPkg = JSON.parse(readFileSync(join(REPO, 'package.json'), 'utf8')) as { + peerDependencies: Record + peerDependenciesMeta?: Record + } + const gen = JSON.parse(readFileSync(join(projectDir, 'package.json'), 'utf8')) as { + dependencies: Record + devDependencies: Record + peerDependencies?: Record + } + // Every @tangle-network engine the template declares (as a runtime dep or a + // peer pin) must be an agent-app peer, at or above agent-app's floor. + const declaredEngines: Record = { + ...gen.peerDependencies, + ...gen.dependencies, + } + for (const [name, range] of Object.entries(declaredEngines)) { + if (!name.startsWith('@tangle-network/') || name === '@tangle-network/agent-app') continue + const floor = appPkg.peerDependencies[name] + expect(floor, `template declares ${name} but it is not an agent-app peer`).toBeTruthy() + expect( + versionGte(minVersion(range), minVersion(floor as string)), + `template pins ${name}@${range}, below agent-app's peer floor ${floor}`, + ).toBe(true) + } + // The engines the chat vertical imports at module top must ride as REAL + // dependencies — a generated app is an application, not a library. + for (const name of [ + '@tangle-network/agent-runtime', + '@tangle-network/sandbox', + '@tangle-network/agent-interface', + 'better-auth', + 'drizzle-orm', + ]) { + expect(gen.dependencies[name], `missing runtime dependency ${name}`).toBeTruthy() + } + // agent-app's REQUIRED (non-optional) engine peers must all be declared + // somewhere installable, or `pnpm install` warns out of the box. + for (const [name] of Object.entries(appPkg.peerDependencies)) { + if (!name.startsWith('@tangle-network/')) continue + if (appPkg.peerDependenciesMeta?.[name]?.optional) continue + expect( + gen.dependencies[name] ?? gen.devDependencies[name], + `agent-app requires peer ${name}; the template installs nothing for it`, + ).toBeTruthy() + } + }) + + it('the generated app typechecks against the real agent-app dist types', () => { + const tsc = join(projectDir, 'node_modules', 'typescript', 'bin', 'tsc') + try { + execFileSync('node', [tsc, '--noEmit', '--project', join(projectDir, 'tsconfig.json')], { + cwd: projectDir, + stdio: 'pipe', + }) + } catch (err: unknown) { + const e = err as { stdout?: Buffer; stderr?: Buffer } + const output = (e.stdout?.toString() ?? '') + (e.stderr?.toString() ?? '') + throw new Error(`generated chat app failed typecheck:\n${output}`) + } + }, 120_000) + + it("the generated app's OWN e2e suite passes: fake producer → turn → stream → persisted parts → replay", () => { + // This is the one-day-claim gate: the template ships a working end-to-end + // test, and CI proves it stays working against the current shell. It also + // executes the dist chunks for real, so an engine package a chunk imports + // at runtime but the template forgot to declare fails here. + const vitestCli = join(projectDir, 'node_modules', 'vitest', 'vitest.mjs') + // Strip the parent runner's VITEST_* worker vars so the child starts clean. + const env = Object.fromEntries( + Object.entries(process.env).filter(([k]) => !k.startsWith('VITEST')), + ) as Record + try { + execFileSync('node', [vitestCli, 'run'], { + cwd: projectDir, + stdio: 'pipe', + env: { ...env, CI: 'true' }, + timeout: 120_000, + }) + } catch (err: unknown) { + const e = err as { stdout?: Buffer; stderr?: Buffer; message?: string } + const output = (e.stdout?.toString() ?? '') + (e.stderr?.toString() ?? '') + throw new Error(`generated chat app's own test suite failed:\n${output || e.message}`) + } + }, 180_000) +}) diff --git a/vitest.config.ts b/vitest.config.ts index 8b0be94..ac638f6 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -3,12 +3,13 @@ import { defineConfig } from 'vitest/config' export default defineConfig({ test: { // The framework's own tests live in `tests/**` and co-located `src/**`. - // EXCLUDE `create-agent-app/template/**`: that is a scaffolder template whose - // tests run inside a GENERATED project, not here (they import the published - // `@tangle-network/agent-app`, absent in this repo's module graph). The - // scaffolder itself is exercised by `tests/create-agent-app.test.ts`. + // EXCLUDE `create-agent-app/template*/**`: those are scaffolder templates + // whose tests run inside a GENERATED project, not here (they import the + // published `@tangle-network/agent-app`, absent in this repo's module + // graph). The scaffolders themselves are exercised by + // `tests/create-agent-app.test.ts` / `tests/create-agent-app-chat.test.ts`. include: ['tests/**/*.test.{ts,tsx}', 'src/**/*.test.{ts,tsx}'], - exclude: ['**/node_modules/**', '**/dist/**', 'create-agent-app/template/**'], + exclude: ['**/node_modules/**', '**/dist/**', 'create-agent-app/template/**', 'create-agent-app/template-chat/**'], // Unmount @testing-library React trees between tests (this repo doesn't run // with `globals: true`, so RTL's auto-cleanup hook isn't registered). setupFiles: ['./src/test-setup.ts'],