From 9b58e6eb09778e44f8d9831669b36e5e6b2f0197 Mon Sep 17 00:00:00 2001 From: yia-mw-agent Date: Thu, 29 Oct 2026 13:28:16 +0000 Subject: [PATCH 1/2] docs: finish the tic-tac-toe guide and split setup into two tracks Extend the tic-tac-toe guide from its opening to the whole guide: the layout and the four operations, what saga handles, one guided change (a move list), connecting to Claude, ChatGPT and Alexa+, and what else the design allows, with two new diagrams. The host sections state that this game has not been run in any of the three hosts, and describe a voice or conversation move as a different interaction from clicking the board. Make the setup guide the short common path: prerequisites, installs, saga init, HTTP defaults, attaching to an existing backend, troubleshooting and the choice of track. Move the Lit composition, evidence and actions overview into build-notes-with-lit.md unchanged, leading into the action guide. Point the README's first steps at both tracks and fix links to the moved sections. Move the affected assertions in guide.test.ts with their material, and extend the tic-tac-toe document test to check the guide's commands, quoted source, patch, links, diagrams and host claims against the example. --- AGENTS.md | 4 +- README.md | 7 +- diagrams/tic-tac-toe-a2a-delivery.mmd | 25 + diagrams/tic-tac-toe-a2a-delivery.png | 3 + diagrams/tic-tac-toe-a2a-delivery.svg | 1 + diagrams/tic-tac-toe-arrangements.mmd | 47 ++ diagrams/tic-tac-toe-arrangements.png | 3 + diagrams/tic-tac-toe-arrangements.svg | 1 + docs/build-notes-with-lit.md | 141 +++++ docs/build-tic-tac-toe-with-react.md | 263 ++++++++- docs/development-reference.md | 5 +- docs/getting-started.md | 161 +----- docs/public-install.md | 4 +- examples/tic-tac-toe/README.md | 2 +- scripts/guide.test.ts | 130 ++++- scripts/tic-tac-toe-document.test.ts | 757 ++++++++++++++++++++++---- scripts/tic-tac-toe.test.ts | 2 +- 17 files changed, 1282 insertions(+), 274 deletions(-) create mode 100644 diagrams/tic-tac-toe-a2a-delivery.mmd create mode 100644 diagrams/tic-tac-toe-a2a-delivery.png create mode 100644 diagrams/tic-tac-toe-a2a-delivery.svg create mode 100644 diagrams/tic-tac-toe-arrangements.mmd create mode 100644 diagrams/tic-tac-toe-arrangements.png create mode 100644 diagrams/tic-tac-toe-arrangements.svg create mode 100644 docs/build-notes-with-lit.md diff --git a/AGENTS.md b/AGENTS.md index 7ce0d43c..083c33c5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,9 +41,9 @@ Read [CODING_STANDARDS.md](CODING_STANDARDS.md) before designing, implementing, - `apps/lit-host` builds the protected player twice from one controller (REACT2): the Lit view (`playback.html`) and a React view (`playback-react.html`, `src/react/play-control.tsx`; no description card). `e2e/playback-view.spec.ts` runs against both through the `lit` and `react` Playwright projects (`e2e/player-renderer.ts`). The React page uses the application's own React runtime and the `core` and `mcp` packages only; it does not use `@saga-a2ui/react`. - `templates/` holds maintained starter templates (STARTER1: `templates/lit-node`, the `lit-mcp` stack on the `node` server target; STARTER-EXPRESS #62: `templates/lit-express`, the same stack on the `express` target; STARTER-FASTIFY #64: `templates/lit-fastify`, the same stack on the `fastify` target; STARTER-HONO #63: `templates/lit-hono`, the same stack on the official `@modelcontextprotocol/hono` adapter, qualified by `scripts/starter-hono.test.ts`). SAGA also owns `templates/lit-hapi`: its maintained `server/bridge.ts` carries Hapi-authenticated request context into the Node MCP transport; `scripts/starter-hapi.test.ts` and `scripts/starter-hapi-identity.test.ts` qualify it without an external repository dependency. A template is not a package and not an example: it is the one source the starter output, the existing-backend snippets and the qualification (`scripts/qualified-starters.ts`, `scripts/starter-qualification.ts`, `scripts/starter.test.ts`, `scripts/starter-express.test.ts`, `scripts/starter-fastify.test.ts`, `scripts/starter-hono.test.ts`, `apps/lit-host/e2e/starter.spec.ts`) all read. Test identities and policy stay in the specifications, never in a template; see `templates/README.md`. - `scripts/saga.ts` is the one CLI parser: `saga init --a2ui-stack -mcp --server DIR` (CLI1) and `saga demo` with the same options (CLI2). `init` composes `exportTemplate` and offers only the pairs in its `SUPPORTED_PAIRS` list (today `lit-mcp` on `node`, `express`, `fastify`, `hono` and `hapi`, each naming its own maintained template; #179); a further pair adds one row there after its own qualification passes. `demo` offers `SUPPORTED_DEMO_PAIRS`, the pairs whose template is `DEMO_TEMPLATE` (`scripts/qualified-starters.ts`), builds the DEMO1 download outside the destination with `stageDemo` (`scripts/build-demo.ts`) and writes it through `createDirectoryInterruptibly`, the destination handling `init` uses too (`scripts/starter-template.ts`). Each creates one new directory and installs and runs nothing of its output; `publish` (CLI3, CLI4) is optional tooling loaded only for that command (`scripts/publish/`, read [its README](scripts/publish/README.md)): one release input and one findings record prepare either target, `chatgpt` (the plugin package) or `claude` (the connector dossier, with a plugin bundle only when the release input supplies its scope). `publish handoff` (CLI5, `scripts/publish/handoff.ts`) keeps the resumable record of the manual submission that follows: numbered, hash-chained event files for preparation, submission, review and a verified listing, with an uncertain attempt inspected before any retry. It deploys, uploads and submits nothing, and local Desktop or Code packaging is never reported as web distribution. -- `docs/getting-started.md` is the setup guide (GUIDE1) and `docs/attach-to-existing-backend.md` its generated existing-backend recipes. Never edit the generated file by hand: `scripts/guide-recipes.ts` (`npm run guide:recipes`) renders it from the qualified templates in `scripts/qualified-starters.ts`, and `scripts/guide.test.ts` checks that it is current and that every command, snippet, package name and link in both documents is real. A newly qualified server joins the recipes by regenerating; a pair joins `saga init` only through `SUPPORTED_PAIRS`. +- `docs/getting-started.md` is the setup guide (GUIDE1): what every app needs, and the choice of two tracks, `docs/build-tic-tac-toe-with-react.md` (checked by `scripts/tic-tac-toe-document.test.ts`) and `docs/build-notes-with-lit.md` (the Lit composition and actions overview, leading into `docs/actions.md`). `docs/attach-to-existing-backend.md` holds its generated existing-backend recipes. Never edit the generated file by hand: `scripts/guide-recipes.ts` (`npm run guide:recipes`) renders it from the qualified templates in `scripts/qualified-starters.ts`, and `scripts/guide.test.ts` checks that it is current and that every command, snippet, package name and link in the setup guide, the Lit track and the recipes is real. A newly qualified server joins the recipes by regenerating; a pair joins `saga init` only through `SUPPORTED_PAIRS`. - `docs/actions.md` is the action guide (GUIDE2). Its quoted source is checked against the files it names by `scripts/guide-actions-document.test.ts`; `scripts/guide-actions.test.ts` runs every case it traces against the notes application installed from packed tarballs. The guide's confirmation and audit prerequisites are qualification source in `scripts/guide-actions/guarded-archive.ts`, not part of the notes example or an SDK export. A qualified Hapi template must add its identity-handoff trace to the guide (the document test fails until it does). -- `examples/tic-tac-toe` is the game of the React getting-started guide (`docs/build-tic-tac-toe-with-react.md`, whose opening is the owner-reviewed sample): generated with the React/Hono `saga init`, keeping the template's structure, with pure rules (`server/rules.ts`), a `GameStore` over a bounded in-memory map, four operations (`new_game` and `show_game` with the board, `get_game` and `play_move` without a view) and a board that appends a message to the conversation. `scripts/build-tic-tac-toe.ts` (`npm run build:tic-tac-toe`) builds it from packed candidate tarballs outside the repository, as for the other examples. It is a development example and not an installation recipe; no host has been run against it. See [the development reference](docs/development-reference.md#the-tic-tac-toe-example). +- `examples/tic-tac-toe` is the game of the React getting-started guide (`docs/build-tic-tac-toe-with-react.md`, checked against the example by `scripts/tic-tac-toe-document.test.ts`): generated with the React/Hono `saga init`, keeping the template's structure, with pure rules (`server/rules.ts`), a `GameStore` over a bounded in-memory map, four operations (`new_game` and `show_game` with the board, `get_game` and `play_move` without a view) and a board that appends a message to the conversation. `scripts/build-tic-tac-toe.ts` (`npm run build:tic-tac-toe`) builds it from packed candidate tarballs outside the repository, as for the other examples. It is a development example and not an installation recipe; no host has been run against it. See [the development reference](docs/development-reference.md#the-tic-tac-toe-example). - `demo/lit-node` is the standalone synthetic demonstration (DEMO1) of the one qualified pair. `scripts/build-demo.ts` builds it from packed candidate tarballs outside the repository into the download `apps/lit-host/dist/demo`. All synthetic identity, media and business data stay in `demo/lit-node/fixtures`; the clean starter and the SDK packages never import it. Its authentication is local-only and stays visibly so. - `demo/tour` is the static source-linked tour (TOUR1): the notes example's real Lit and React views and action runtime, mounted by the local host fixture over a simulated server that answers from `demo/lit-node/fixtures/notes.ts` data. `scripts/build-tour.ts` (`npm run build:tour`) builds it from packed candidate tarballs outside the repository into `apps/lit-host/dist/tour`. It has no backend, credential or service, and a simulated answer is not server or host evidence. `scripts/tour.test.ts` requires the simulated server to answer as the real `createNotesServer` does and checks every quoted source line and guide heading it links; `apps/lit-host/e2e/tour.spec.ts` drives the page. Renderer previews are offered only while they work; Lynx is not offered. - **Documentation:** Before creating or revising documentation, follow [CONTRIBUTING.md#documentation](CONTRIBUTING.md#documentation). Before capturing or retaining verification evidence, follow [docs/change-hygiene.md#evidence](docs/change-hygiene.md#evidence). diff --git a/README.md b/README.md index 277c0906..27e004a1 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,12 @@ For a browser walkthrough of actions and their implementation, try the [interact ## Build your application -React and Lit are both supported application paths. Their MCP stacks provide typed surfaces, action controls and the optional FaithfulCard application, with framework-specific rendering and lifecycle integration. Choose the stack that matches your application. Follow the [React integration guide](docs/react-consumers.md) for React, or [Getting started](docs/getting-started.md) for either stack and the generated Lit starters. [Ansari Faithful Answers uses the React stack](docs/react-consumers.md#react-in-ansari-faithful-answers). The demo and generated starter templates currently use Lit; choose by framework and the workflow you need, rather than treating template availability as renderer maturity. +React and Lit are both supported application paths. Their MCP stacks provide typed surfaces, action controls and the optional FaithfulCard application, with framework-specific rendering and lifecycle integration. Choose the stack that matches your application. Start with [Getting started](docs/getting-started.md), which covers what every application needs, then follow one of two tracks: + +- [Build tic-tac-toe with React](docs/build-tic-tac-toe-with-react.md): a game that a person and an AI assistant play together. It reads in about ten minutes and is the quickest way to see what saga does. +- [Build the notes application with Lit](docs/build-notes-with-lit.md): a view composed with the Lit stack, leading into the [action guide](docs/actions.md). + +To add the React stack to an application you already have, use the [React integration guide](docs/react-consumers.md). [Ansari Faithful Answers uses the React stack](docs/react-consumers.md#react-in-ansari-faithful-answers). The demo and five of the six generated starters use Lit, and one starter uses React on Hono; choose by framework and the workflow you need, rather than treating template availability as renderer maturity. For Hapi, the [integration map](templates/lit-hapi/README.md#existing-hapi-app-register-an-endpoint) shows how to reuse the [bridge](templates/lit-hapi/server/bridge.ts) in your own app, with a registration example, bridge-owned lifecycle handling and the application’s responsibilities. diff --git a/diagrams/tic-tac-toe-a2a-delivery.mmd b/diagrams/tic-tac-toe-a2a-delivery.mmd new file mode 100644 index 00000000..d07a8eb4 --- /dev/null +++ b/diagrams/tic-tac-toe-a2a-delivery.mmd @@ -0,0 +1,25 @@ +sequenceDiagram + actor P as Person + participant B as Board (A2UI view) + participant C as A2A client + participant G as Game agent (A2A server) + participant S as App API (rules, GameStore) + Note over B,G: Architectural sketch: no A2A delivery exists yet. + P->>B: clicks a square + B->>S: play_move(X, expectedMoveNumber 0) + S-->>B: game, move 1 + B->>B: show X, squares disabled + B->>C: appendMessage("Your move in game ...", context: game) + C->>G: message/send
role user, current contextId
parts: text, data {game} + opt data part missing + G->>S: get_game + S-->>G: game, move 1 + end + G->>S: play_move(O, expectedMoveNumber 1) + S-->>G: game, move 2 + G-->>C: reply message + loop bounded wait, about every 1.5 s + B->>S: get_game + S-->>B: game, move 2 + end + B->>B: show O, squares enabled for X diff --git a/diagrams/tic-tac-toe-a2a-delivery.png b/diagrams/tic-tac-toe-a2a-delivery.png new file mode 100644 index 00000000..c005e2b9 --- /dev/null +++ b/diagrams/tic-tac-toe-a2a-delivery.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:822d838a4106607bcd216339e6f8e7002c556bdf22ec84564e407410f7634b8c +size 111464 diff --git a/diagrams/tic-tac-toe-a2a-delivery.svg b/diagrams/tic-tac-toe-a2a-delivery.svg new file mode 100644 index 00000000..a8b82e3d --- /dev/null +++ b/diagrams/tic-tac-toe-a2a-delivery.svg @@ -0,0 +1 @@ +App API (rules, GameStore)Game agent (A2A server)A2A clientBoard (A2UI view)App API (rules, GameStore)Game agent (A2A server)A2A clientBoard (A2UI view)Architectural sketch: no A2A delivery exists yet.opt[data part missing]loop[bounded wait, about every 1.5 s]Personclicks a squareplay_move(X, expectedMoveNumber 0)game, move 1show X, squares disabledappendMessage("Your move in game ...", context: game)message/sendrole user, current contextIdparts: text, data {game}get_gamegame, move 1play_move(O, expectedMoveNumber 1)game, move 2reply messageget_gamegame, move 2show O, squares enabled for XPerson \ No newline at end of file diff --git a/diagrams/tic-tac-toe-arrangements.mmd b/diagrams/tic-tac-toe-arrangements.mmd new file mode 100644 index 00000000..178a28a1 --- /dev/null +++ b/diagrams/tic-tac-toe-arrangements.mmd @@ -0,0 +1,47 @@ +flowchart TB + classDef runnable fill:#dcfce7,stroke:#16a34a,stroke-width:2px,color:#14532d + classDef compose fill:#dbeafe,stroke:#2563eb,stroke-width:2px,color:#1e3a8a + classDef sketch fill:#ffffff,stroke:#6b7280,stroke-width:2px,stroke-dasharray:6 4,color:#374151 + + subgraph Layout["Layout: what the board says"] + A2UI["A2UI layout and data
surface.ts"]:::runnable + end + subgraph Draw["Renderer: who draws it"] + React["React catalog and view
this guide"]:::runnable + Lit["Lit catalog and view"]:::compose + end + subgraph Carry["Delivery: how it travels"] + MCPApps["MCP Apps card in a host
this guide"]:::runnable + Native["Native A2UI host
no MCP"]:::sketch + A2A["A2A client
appendMessage as message/send"]:::sketch + end + subgraph Ops["Operations: what the board and the assistant call"] + One["One server
MCP endpoint, rules and store
this guide"]:::runnable + Gateway["Gateway server
MCP endpoint only"]:::compose + API["App API server
rules and store"]:::compose + end + subgraph Store["GameStore: where games live"] + Memory["In-memory map
this guide"]:::runnable + Remote["Workers KV or
Durable Object"]:::sketch + end + subgraph Key["Key"] + K1["Runnable: the guide's own app"]:::runnable + K2["Straightforward composition:
existing packages"]:::compose + K3["Architectural sketch:
needs adapters that do not exist yet"]:::sketch + end + + A2UI --> React + A2UI --> Lit + A2UI -.-> Native + React --> MCPApps + Lit --> MCPApps + React -.-> A2A + MCPApps --> One + MCPApps --> Gateway + Native -.-> API + A2A -.-> API + Gateway --> API + One --> Memory + API --> Memory + API -.-> Remote + Layout ~~~ Key diff --git a/diagrams/tic-tac-toe-arrangements.png b/diagrams/tic-tac-toe-arrangements.png new file mode 100644 index 00000000..bc21b06d --- /dev/null +++ b/diagrams/tic-tac-toe-arrangements.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6fb9050edc3e5b77b76aafbb79bc7b7606042475c9ae586645d272b8c3bd1c7a +size 175802 diff --git a/diagrams/tic-tac-toe-arrangements.svg b/diagrams/tic-tac-toe-arrangements.svg new file mode 100644 index 00000000..6763f20d --- /dev/null +++ b/diagrams/tic-tac-toe-arrangements.svg @@ -0,0 +1 @@ +

Key

GameStore: where games live

Operations: what the board and the assistant call

Delivery: how it travels

Renderer: who draws it

Layout: what the board says

A2UI layout and data
surface.ts

React catalog and view
this guide

Lit catalog and view

MCP Apps card in a host
this guide

Native A2UI host
no MCP

A2A client
appendMessage as message/send

One server
MCP endpoint, rules and store
this guide

Gateway server
MCP endpoint only

App API server
rules and store

In-memory map
this guide

Workers KV or
Durable Object

Runnable: the guide's own app

Straightforward composition:
existing packages

Architectural sketch:
needs adapters that do not exist yet

\ No newline at end of file diff --git a/docs/build-notes-with-lit.md b/docs/build-notes-with-lit.md new file mode 100644 index 00000000..2089c692 --- /dev/null +++ b/docs/build-notes-with-lit.md @@ -0,0 +1,141 @@ +# Build the notes application with Lit + +This is the Lit track. It starts after the [setup guide](getting-started.md): you have installed the packages or built the candidate tarballs, and you know which renderer and server you are using. The React track is [Build tic-tac-toe with React](build-tic-tac-toe-with-react.md); it builds a different application, so you do not need this page to follow it. + +The track is organised around the notes application (`examples/pack1-consumer`): a list of notes, each with an Archive control that the server authorizes. It shows how a Lit view is composed with the stack, how identity reaches an operation, and where the evidence for each claim stops. Run the demonstration first to see the finished application, read how the view is composed, then continue to the [action guide](actions.md), which changes a component and traces allowed and denied requests. + +## Start from the demo + +The demonstration is a synthetic, local-only application of one qualified pair, built from packed candidate tarballs outside the repository. It is the fastest way to see the workflow: browse, descriptions at both content levels, protected Play, and the notes actions. It is **not a starter** and not a release download; no hosted copy exists. + +Use a source checkout with Node 22.23.3 or newer (the repository toolchain minimum). From its root: + +```sh repository +npm ci +npm run build:demo +``` + +```sh demo +npm install --omit=dev +npm start +``` + +The first block writes `apps/lit-host/dist/demo`; run the second inside it. Open the `Open the demo host page:` address it prints. The generated demo itself requires Node 22.12.0 or newer. Choose Alice in the local host page, open Notes, and press Archive on a note. The row reports the archive result. End the notes session and try another Archive: the prerequisite refuses and no archive request is sent. These are synthetic local credentials, not a production identity flow. + +See the [demonstration README](../demo/lit-node/README.md) for the controls, the environment variables and what each shows. + +## Compose a view with a stack + +An application installs one stack plus its framework peers and writes only what is its own. The stack connects to the host, holds a result that arrives before the view is ready (the latest wins), gives each result to the application, and ends the view once, before the connection ends, when the host tears it down, `dispose()` runs or the page goes away. A raw `pagehide` listener is not needed for any of that; the application keeps one only for behavior that is its own. + +The Lit starter's composition is this. It is excerpted line for line from `templates/lit-node/src/view.ts`, and `scripts/guide.test.ts` fails when a line stops being there or when the call omits an option `createLitActions` requires. The layout is in `surface.ts` and the catalog in `catalog.ts`. `requireElement`, `say`, `show` and `retire` are the application's own helpers in the same file: what a result means and what ending the view takes with it. `retire` ends only what the application owns. The surface owns the discovery and the bindings made in `attach`, and ends them when it leaves the page, before the stack calls `onTeardown`. + +```ts +import { + ActionButton, + createLitActions, + startA2uiView, +} from '@saga-a2ui/lit-mcp-stack'; + +const view = startA2uiView({ + container: requireElement('starter-app'), + layout: starterSurface, + catalog: starterCatalog, + identity: { name: 'saga-starter-view', version: '0.0.0' }, + actions: createLitActions({ + attach: (surfaceView) => { + surfaceView.watchById( + CLEAR_ID, + ActionButton, + (handle) => void handle.bindAction('activate', actions.clearNote), + ); + }, + onSetupError: () => say(PROBLEM_TEXT), + }), + onToolResult: (result, running) => show(result, running.surface), + onTeardown: retire, +}); +await view.ready; +``` + +`attach` runs once for each presented surface and binds the application's action to the control it finds. `onSetupError` is required: it receives the error of a setup that throws, and the page shows only the safe fallback while the text goes to your callback. In `show`, `surface.update({ headline })` validates the data against the layout and applies it in place: the mounted controls, their bindings and focus survive, and a rejected update changes nothing. A card application states a validated definition instead (`FaithfulCardApp.define(...)`) and starts it with `startFaithfulCardApp` from `@saga-a2ui/lit-mcp-stack/faithful-card` or `@saga-a2ui/react-mcp-stack/faithful-card`. Neither stack asks the application to write A2UI messages, to import the MCP Apps bridge, or to order the handshake; the repository's consumer boundary scan (`scripts/consumer-boundary.test.ts`) fails a template, example or view module that does. The catalog is written against the stack's own exports too (`A2uiLitElement`, `basicCatalog`, `Catalog`, `CommonSchemas`, `z`), so an application that installs the stack names no upstream renderer or schema package. + +A stack's installed tree includes `@saga-a2ui/components` and `markdown-it` as its declared dependencies; the emitted bundle of an application that uses only the main entry does not (the card entry is where they are used). The [React consumer dependency guide](react-consumers.md) has the React recipe and the registry-versus-candidate distinction. + +## Content policy + +A view that shows supplied Markdown (the optional FaithfulCard) does so under one **trusted content policy** that the application chooses in its own server code. Neither the model nor the supplied content sets it. + +- **Standard** permits validated HTTPS links through the host, and images from explicitly allowed HTTPS hosts. +- **Strict** keeps every link inert, shows blocked fragments as accessible placeholders (or inert text) that the reader can reveal as text, and loads a permitted image only after the reader chooses **Show images**. Consent is revoked by a new result or by closing the view. + +Both keep ordinary formatting and both retain server authorization and data-sharing checks. The starter itself shows no Markdown: its view is served with the closed policy `CLOSED_VIEW_CSP` from `@saga-a2ui/mcp/policy`, which allows no network or external resource. Ansari's branding and presentation rules stay with Ansari. + +### Rebuild and resource matching + +A view is served as an MCP App resource whose address is `ui://views/.<32-character-sha256-prefix>.html`, the SHA-256 of the exact bytes served. A view that embeds a policy has an address that depends on that policy, so **changing the policy or rebuilding the view changes the address**. Rebuild the view from the installed packages, restart the server so it serves the rebuilt view, and pair a server only with the view built beside it. A view that meets a result it does not understand shows that the view and its server are from different builds instead of rendering it. `npm run candidate:identity` prints the identities for a given policy. + +## Identity and the evidence behind it + +The application authenticates the request and its server decides what the account may see; SAGA carries the verified result. In the starter, `server/listener.ts` verifies the request, hands the result to the transport, and the operation reads it with `identityOf`. No tool argument can name an account, a prior call authorizes nothing, and two overlapping requests each see their own identity. + +**Synthetic versus real identity.** The demonstration's sign-in choices are fixed public development credentials printed in the source (`apps/lit-host/src/mcp/dev-auth.ts`). They prove nothing about a person, the page and the process say so, and the process refuses `NODE_ENV=production`. They are not a model for production. A real host uses its supported authentication through your own `OAuthTokenVerifier`, and a loopback run says nothing about it. + +**Evidence is several different things.** A claim is only as wide as the check that supports it: + +| Evidence | It shows | It does not show | +| --- | --- | --- | +| Invocation | An action ran against an instance, with its prerequisites, hooks and outcome | That the control was visible, or that a host delivers the gesture | +| Rendering | A view displayed content under a policy in the local host fixture | Rendering in ChatGPT, Claude or Alexa+ | +| Authentication | A request with an accepted, refused or absent credential was admitted or refused | An OAuth or DCR flow, or any real identity provider | +| Audio | The local audio clock advanced | Audio in a remote host | + +Named-host receipts exist for a few specific flows and are recorded in [host compatibility observations](host-compatibility.md). The starter, the demonstration and this guide have not been run in an actual assistant host. + +## Actions + +The attachment above is the whole of what this guide teaches. [Change a component and trace allowed and denied requests](actions.md) is where the details belong: it changes a component and its action, then traces input, prerequisites, verified identity, authorization, delivery and cleanup, with every case run. This section is the short version. + +The verified **non-playback** example is the notes application: `examples/pack1-consumer/src/notes-actions.ts` defines Archive once and `notes-view.ts` attaches it to each rendered row. The demonstration serves it behind the starter's own authentication, and its README shows each outcome: + +- **Asynchronous prerequisites.** Checking that the session is still valid is a prerequisite that runs before the handler. If it refuses, no protected request is sent. +- **Outcome hooks.** Success, failure and cancellation each have a hook that reports what that outcome establishes. A timeout or an unreadable reply is *unknown*, not "nothing happened". +- **Application resource ownership.** A hold the application takes for a press is given back on every non-success end and handed to its owner only after the server confirms. The application owns it, not the binding. + +Two limits matter before you rely on a client-side control: + +- **A client check does not authorize a protected server operation.** The server decides again for every protected request; a prerequisite only saves a request. +- **Cancellation does not undo remote effects.** Closing a view stops the client waiting. It does not establish that the host forwarded cancellation or that the server stopped, and an applied write stays applied. Uncertain writes are not retried automatically. + +Defaults, kept separate: action work 30 seconds and each hook or cleanup 5 seconds; server operations 3 seconds inside a 10-second request budget that starts before authentication. Invalid explicit settings fail, and a stage never resets its parent's budget. + +## What is implemented and what is not + +| Area | Implemented, qualified in source | Not done or not shown | +| --- | --- | --- | +| Shared action contract | `@saga-a2ui/core/actions`, with the opt-in Lit and React `ActionButton` controls; Play runs on it | A registry or handler-name table, which do not exist | +| Lit | Published renderer and MCP stack: typed surfaces, actions and FaithfulCard applications; packed-consumer checks cover the starter, demonstration and notes example | Starter and local fixture results do not establish every application or host combination; Windows is not qualified here | +| React | Published renderer and MCP stack: typed surfaces, actions and FaithfulCard applications; packed-consumer and browser checks cover own-catalog notes and branded answer cards. Bounded notes observations in Claude/ChatGPT are recorded in [host compatibility](host-compatibility.md) | One generated React starter, `react-mcp` on Hono; no other React server pair and no React demonstration. Qualification of a particular application and host remains specific to its built artifact; no complete host matrix is claimed | +| Server targets | `node`, `express`, `hono`, `fastify`, `hapi` qualified with `lit-mcp`; `hono` qualified with `react-mcp` | No full matrix; `saga init` offers those six pairs; the standalone demonstration is the Lit and Node application only | +| Lynx | Nothing. Lynx delivery was retired on 2026-10-19; `apps/lynx-fixture` and the Lynx proposals in the Blueprints are **historical** designs and evidence, with no delivery promise, no recipe and no adapter | Any current Lynx setup | + +A renderer is qualified by running its own lifecycle, not by declaring that it could: a type declaration that compiles is not a working setup claim, and no renderer is forced through a Lit-specific lifecycle (custom elements and `createA2uiAppElement`). React attaches through its own provider and hook; the shared contract is renderer-neutral. + + +### React applications + +For MCP delivery, use `@saga-a2ui/react-mcp-stack` and its exports, as in the [complete React application](react-consumers.md#follow-a-complete-react-application). If you deliberately compose below the stack, use SAGA's React A2UI entry point for public surface and catalog APIs. Do not add direct dependencies on `@a2ui/react` or `@a2ui/web_core`, or import their catalog or surface APIs yourself: + +```ts +import {A2uiSurface, Catalog, type ReactCatalogComponent} from '@saga-a2ui/react/a2ui'; +``` + +`@saga-a2ui/react/a2ui` is the supported surface. Keep React and React DOM as your declared peers, pin `@a2ui/markdown-it` 0.2.1 at the consumer root, and keep the separate Zod majors apart: [the React consumer dependency guide](react-consumers.md) is the recipe. Any later React pair exposure keeps its own renderer qualification. + + +## Where to go next + +- Change a component and trace allowed and denied requests: the [action guide](actions.md). It is the next step of this track. +- Install, create a project, serve over HTTP or attach to an existing backend: the [setup guide](getting-started.md). +- Build a different application with the React stack: [Build tic-tac-toe with React](build-tic-tac-toe-with-react.md). +- Where each host has been observed to work: [host compatibility](host-compatibility.md). diff --git a/docs/build-tic-tac-toe-with-react.md b/docs/build-tic-tac-toe-with-react.md index 22d49172..bf9005b2 100644 --- a/docs/build-tic-tac-toe-with-react.md +++ b/docs/build-tic-tac-toe-with-react.md @@ -2,9 +2,9 @@ You play tic-tac-toe against an AI assistant, inside the chat. You click a square on a board. The assistant answers by playing O. Both of you use the same game, so neither can cheat the other: the server checks every move. -This guide builds that game with saga a2ui and React. It is a weekend-sized project, and you can read it in about ten minutes. +This guide builds that game with saga a2ui and React. It is a weekend-sized project, and you can read it in about ten minutes. Ten minutes is reading time. Installing, building and connecting a host take longer. -> **Draft.** This is the opening of the guide: the finished game and the first click. The layout, the operations, a guided change and the host connections follow in later sections. +By the end you can say how a click and an assistant turn share one game, which files you would change to make your own app, what saga handles for you, and what to do next. ## The finished game @@ -36,9 +36,11 @@ The first command installs the repository's tools. The second packs the SDK, ins Tic-tac-toe MCP endpoint: http://127.0.0.1:3000/mcp ``` -Add that address to an MCP host that can show an app, then ask the assistant for a game. The guide's last section covers Claude, ChatGPT and Alexa+. None of the three has been run against this game yet. The [host compatibility guide](host-compatibility.md) records what each host is known to support. +This route builds the SDK packages from your checkout. The game needs that: adding a message to the conversation and operations that open no card come with the source packages (`0.8.0-rc.1`), and the `0.7.7-rc.1` release on npm does not have them. Nothing here is a published artifact. -You can also try the board without any host. The browser tests mount it in a small local stand-in for a host, `apps/lit-host/e2e/host-fixture`, and play whole games there: clicks, keyboard moves, the assistant's replies and the messages the board sends. See the [development reference](development-reference.md#local-development) for the commands. +Add the address to an MCP host that can show an app, then ask the assistant for a game. [Connecting to Claude, ChatGPT and Alexa+](#connect-it-to-claude-chatgpt-and-alexa) below covers the three hosts. None of the three has been run against this game yet. The [host compatibility guide](host-compatibility.md) records what each host is known to support. + +You can also try the board without any host. The browser tests mount it in a small local stand-in for a host, `apps/lit-host/e2e/host-fixture`, and play whole games there: clicks, keyboard moves, the assistant's replies and the messages the board sends. [The guided change below](#make-one-change-a-move-list) runs one of them. ## Your first click @@ -60,4 +62,255 @@ If the host takes no message at all, the status line tells you what to do: "Tell When your move ends the game, the board sends a different message instead, "I won our tic-tac-toe game." or "Our tic-tac-toe game ended in a draw.", and waits for nothing. -Next: the layout of the example, and the four operations behind it. +## The layout and the operations + +The server (`server/`) holds the game: the rules in `rules.ts`, the stored games in `game-store.ts` and the four operations in `operations.ts`. The board (`src/`) shows it: the layout in `surface.ts`, the components that draw it in `catalog.tsx`, the actions in `actions.ts` and where each is attached in `view.tsx`. `contract.ts` and `text.ts` hold the shape of a game and every sentence anyone reads. + +The layout is written in A2UI, a format that describes an interface as data: named components and the values they show. It says "a status line, nine squares, a New game button" and names no behavior. + +The server offers four operations: + +| Operation | Called by | Opens the board | Returns | +| --- | --- | --- | --- | +| `new_game` | Assistant or board | Yes | A new game | +| `show_game` | Assistant | Yes | Opens or reopens the board on a game | +| `get_game` | Assistant or board | No | The current game | +| `play_move` | Assistant or board | No | The game after the move | + +`play_move` takes a game identifier, a square from 0 to 8, the player and `expectedMoveNumber`. The last one is the stale-state protection: a click made on an old board, or sent twice, names a move number the game has already passed, and the server refuses it with no effect. + +All four are declared with `registerPublicOperation`. The two that return only data leave out `view`: + +```ts +registerPublicOperation( + server, + { + name: GET_GAME_TOOL, + title: 'Read a game', + // ... + annotations: READS, + input: GameIdInputSchema, + output: GameOutputSchema, + failures, + handle: async ({ gameId }) => gameFound(await store.get(gameId)), + project, + }, + { lifetime }, +); +``` + +`new_game` and `show_game` add `view: views.newGame` and `view: views.showGame`, which attach the board. The split matters because a host shows a result on the card it opened for that call: if the assistant read the game with `show_game` on every turn, each turn would open another board. `get_game` and `play_move` return data and a short text for the model, and open nothing. + +The click and the assistant's turn reach the same `play_move`, but they reach the board differently, because the host routes a result by who made the call: + +![The person's click and the assistant's tool call reach the same play_move operation; the board learns each move through its own server calls and hands the turn over by appending a message.](../diagrams/tic-tac-toe-turn-loop.svg) + +[Editable Mermaid](../diagrams/tic-tac-toe-turn-loop.mmd) · [PNG](../diagrams/tic-tac-toe-turn-loop.png) + +**Your move.** The board makes the call, so the result comes back to the action that made it. That action is one definition, shared by all nine squares (`src/actions.ts`): + +```ts +const playSquare = Action.server( + 'play-square', + host.server, +) + .input((event) => { + const shown = game.current(); + if (shown === undefined) throw new Error('no game is on screen'); + return { + gameId: shown.gameId, + expectedMoveNumber: shown.moveNumber, + square: Number(event.read('index')), + }; + }) + // ... + .run(async ({ params, call }) => + readMove(await call(PLAY_MOVE_TOOL, { ...params, player: 'X' })), + ) +``` + +The same file's `handOver` function then adds the message. The copy of the game for the assistant travels as the message's `context`, and the game opts in to sending the message even if the host cannot take that copy: + +```ts +context: { + text: CONVERSATION.boardForAssistant(played), + data: { game: played }, + bestEffort: true, +}, +``` + +**The assistant's move.** The model makes the call, and a host delivers a model call's result to a card it opened for that call, not necessarily to the board that is already on screen. So the board reads the game itself while it waits. + +Each square is bound to the action in `src/view.tsx`, and the layout names no action: + +```ts +surfaceView.watchById( + SQUARE_ID, + ActionButton, + (handle) => + void handle.bindAction('activate', actions.playSquare, { + // ... + subject: () => game.subject(), + }), +); +``` + +If a call fails or times out, the move may or may not have been applied. The board reads the game again before it offers a square. + +## What saga handles + +You write the game: its rules, where it is stored, the layout, the components, the actions and the sentences. saga handles the plumbing: + +- **Operations.** It checks every input and output against the contracts you declare, enforces time limits, ends cancelled work, and turns a failure into a fixed safe message. +- **The board.** It serves the board as an operation's card, connects it to the host, keeps a result that arrives early, and ends the view once when the host or page goes away. +- **Actions.** It attaches one definition to each control with state of its own, so nine squares share one action. +- **The conversation.** It adds the message and its context, and says whether the host took them. + +## Make one change: a move list + +The guided change adds a list of the moves so far under the board. The game already carries its `history`, and the board already turns each move into a line of words, so the change touches only the layout and the catalog. + +```diff +diff --git a/src/catalog.tsx b/src/catalog.tsx +--- a/src/catalog.tsx ++++ b/src/catalog.tsx +@@ -81,6 +81,18 @@ const Board: ReactCatalogComponent = createComponentImplementation( + ), + ); + ++/** The moves so far, one per line, in the order they were played. */ ++const MoveList: ReactCatalogComponent = createComponentImplementation( ++ { ...ListApi, name: 'MoveList' }, ++ ({ props, buildChild }) => ( ++
    ++ {childrenOf(buildChild, props.children).map((move, at) => ( ++
  1. {move}
  2. ++ ))} ++
++ ), ++); ++ + const SquareFaceApi = { + name: 'SquareFace', + schema: z +@@ -129,6 +141,7 @@ export const boardEntries: readonly ReactCatalogComponent[] = [ + Column, + Row, + Board, ++ MoveList, + Line, + SquareFace, + Shown, +diff --git a/src/surface.ts b/src/surface.ts +--- a/src/surface.ts ++++ b/src/surface.ts +@@ -46,7 +46,7 @@ export const boardSurface = defineSurface({ + root: { + component: 'Column', + bind: {}, +- props: { children: ['status', 'board', 'controls'] }, ++ props: { children: ['status', 'board', 'controls', 'history'] }, + }, + components: [ + { +@@ -98,6 +98,12 @@ export const boardSurface = defineSurface({ + when: { path: '/canCheck' }, + child: CHECK_ID, + }, ++ { ++ id: 'history', ++ component: 'MoveList', ++ children: { componentId: 'move', path: '/history' }, ++ }, ++ { id: 'move', component: 'Line', kind: 'body', text: { path: 'text' } }, + { id: CHECK_ID, component: 'ActionButton', child: 'check-label' }, + { + id: 'check-label', +``` + +In `surface.ts` the layout gains a `history` component that repeats a `move` line for each entry of the game's `history`. In `catalog.tsx` a new `MoveList` component draws those lines as an ordered list. The server, the controller and the actions do not change. The patch is `examples/tic-tac-toe/guided-change/move-list.patch`. + +The build you already ran also built the changed game, in `apps/lit-host/dist/tic-tac-toe-guided`. Stop the first server with Ctrl-C, because both use port 3000, and start the changed one: + +```sh repository +node apps/lit-host/dist/tic-tac-toe-guided/server.mjs +``` + +After your click on the centre square the list reads "1. You: row 2, column 2". When the assistant has played, it adds a second line such as "2. Assistant: row 1, column 1". + +To watch the change work without a host, run its browser test on the local host fixture, a stand-in for a host that records what the board appends to the conversation. The test clicks a square, then plays O by calling the server directly, because no assistant is involved. Build the fixture's page first, because building it clears the example's build: + +```sh repository +npm run build -w apps/lit-host +npm run build:tic-tac-toe +cd apps/lit-host +npx playwright install chromium +npx playwright test e2e/tic-tac-toe.spec.ts -g "adds a move list" +``` + +This is a local check. It does not show that an assistant played, or that any host behaves the same way. + +## Connect it to Claude, ChatGPT and Alexa+ + +None of the three has been run against this game, so this guide claims nothing about them. What follows is what to expect from the [host compatibility guide](host-compatibility.md), which records bounded runs of other saga apps. + +A host reaches your server over the network, so the loopback address is not enough. Put the server behind a public HTTPS address, add its host name to `ALLOWED_HOSTNAMES` in `server/listener.ts`, rebuild, and register the address in the host's own connector or app settings, following the host's documentation. The game has no sign-in, so anyone who holds a game's identifier can play either side. Games live in memory and expire thirty minutes after their last move. + +No recorded run covers this game's hand-over: a card appending a message and its context, and an assistant answering it. How long a host waits for a call also matters. One recorded Claude run ended its wait at about 30 seconds, and in recorded ChatGPT and Claude runs some first loads of a card needed the host's Try again button. + +### Claude + +Claude on the web has displayed saga views from a tool result and run calls from a card to the server, in the recorded notes application. Expect the board to open and a click to play X. Whether Claude takes the board's message, and the assistant then plays O, is unverified. If the message is not taken, the status line says "Tell the assistant it's their move." and you can type it. Details: [host compatibility](host-compatibility.md#chatgpt-and-claude). + +### ChatGPT + +ChatGPT on the web has the same recorded display and card calls, and one recorded reload removed a pending view. A reloaded board can be opened again on its game with `show_game`. The hand-over is unverified, as for Claude. Details: [host compatibility](host-compatibility.md#chatgpt-and-claude). + +### Alexa+ + +The Alexa+ simulator has displayed saga views from a conversation, but calls from a card already on screen to the server did not complete usably in the recorded probes ([host compatibility](host-compatibility.md#alexa)). The board's click and its wait are such calls, so do not expect them to work there. + +You would play by voice instead: you say your move, and the assistant calls `play_move` for X and then for O, and `show_game` to show the board. The tool's description allows this when you state your move in the conversation. A voice or conversation move is a different interaction from clicking the board. There is no click, no message appended by the board and no wait, and the board only displays. The server applies the same rules. That Alexa+ behaves this way with this game is unverified. + +## What else this design allows + +The game keeps four things apart: what the board says (the A2UI layout), who draws it (a renderer), how it reaches the person (a delivery), and where the game lives (the server and its store). Each can change on its own. Each arrangement below carries one of three labels: + +| Label | What it means | +| --- | --- | +| Runnable | The guide's own app runs this way. | +| Straightforward composition | Built from existing packages. | +| Architectural sketch | Needs adapters that do not exist yet. | + +![The layout, renderer, delivery, server and store layers, with each arrangement marked runnable, a straightforward composition or an architectural sketch.](../diagrams/tic-tac-toe-arrangements.svg) + +[Editable Mermaid](../diagrams/tic-tac-toe-arrangements.mmd) · [PNG](../diagrams/tic-tac-toe-arrangements.png) + +- **The game as built.** *Runnable.* React draws the board, MCP Apps delivers it, and one Hono server holds the endpoint, the rules and an in-memory store. +- **A gateway and an app API on separate servers.** *Straightforward composition.* Put the rules and the store behind an HTTP API that other clients can use too, and make the MCP server a thin gateway whose operations call it. `GameStore` is the seam. The API must keep the check-and-apply step of `GameStore.play` atomic. +- **Renderer independence.** *Straightforward composition.* The contract, the rules and the server import nothing from React, and the layout is plain A2UI data. A Lit view of the same game needs its own catalog and view on `@saga-a2ui/lit-mcp-stack`, which the [Lit track](build-notes-with-lit.md) shows. +- **A native A2UI host without MCP.** *Architectural sketch.* The layout is data that any host able to render A2UI could draw. The board reaches the server through `host.server` and the conversation through `host.conversation`, which such a host would supply. No such adapter exists in this repository. +- **A2A delivery.** *Architectural sketch.* A2A is a protocol for agents to message one another. `appendMessage` would become an A2A `message/send`, and its `context` would become a data part on that message. The [A2A delivery issue](https://github.com/MachineWisdomAI/saga-a2ui/issues/198) owns the work, and nothing sends it today. +- **`GameStore` on Workers KV or a Durable Object.** *Architectural sketch.* A Durable Object serializes the requests for one game, so it can give `play` its one-step check. Workers KV has no conditional write on the move number. The repository ships neither store, and its listener is a Node listener, so the endpoint would also need a Workers host. + +The A2A sketch, as one turn of the game: + +![A sketch of the turn loop over A2A: the board's appendMessage becomes one message/send carrying a text part and a data part with the game, the game agent plays O against the app API, and the board keeps reading the game while it waits.](../diagrams/tic-tac-toe-a2a-delivery.svg) + +[Editable Mermaid](../diagrams/tic-tac-toe-a2a-delivery.mmd) · [PNG](../diagrams/tic-tac-toe-a2a-delivery.png) + +## Where to go next + +Start your own project from the same starter the example came from: + +```sh repository +npx tsx scripts/saga.ts init --a2ui-stack react-mcp --server hono ../my-saga +``` + +It creates one new directory and installs nothing. The [setup guide](getting-started.md#create-a-new-project) covers installing and starting it, and the [example's README](../examples/tic-tac-toe/README.md) maps the example's files and tests. Replace the rules, the store and the layout with your own, and keep the operations, the action and the conversation message as the pattern. + +Other places to go: + +- [React integration](react-consumers.md): add the React stack to an application you already have. +- [Build the notes application with Lit](build-notes-with-lit.md): the Lit track, which leads to the [action guide](actions.md) and its traced allowed and denied requests. +- [Attach saga to an existing backend](attach-to-existing-backend.md): add an endpoint to a server you run. diff --git a/docs/development-reference.md b/docs/development-reference.md index b9e2b61d..87283e88 100644 --- a/docs/development-reference.md +++ b/docs/development-reference.md @@ -2,7 +2,7 @@ Build, test and implementation notes accumulated as the SDK evolved. Dated slices describe their own evidence and limitations; use [Getting started](getting-started.md) for the current setup path and the [project overview](../README.md) for current scope. -New here: [Getting started](../docs/getting-started.md) takes you to the demonstration, a new project from a qualified starter, or an attachment to an existing backend, with every command checked against the repository. [Public installation](public-install.md) covers Lit and React registry recipes; local qualification and Lit starters use separately built candidate packages. To change a component and trace allowed, denied and unauthenticated requests through an action, read [Change a component and trace allowed and denied requests](actions.md). +New here: [Getting started](../docs/getting-started.md) takes you to a new project from a qualified starter or an attachment to an existing backend, and to two tracks, [Build tic-tac-toe with React](build-tic-tac-toe-with-react.md) and [Build the notes application with Lit](build-notes-with-lit.md), whose Lit track runs the demonstration. Every command is checked against the repository. [Public installation](public-install.md) covers Lit and React registry recipes; local qualification and Lit starters use separately built candidate packages. To change a component and trace allowed, denied and unauthenticated requests through an action, read [Change a component and trace allowed and denied requests](actions.md). React applications: follow [the React consumer dependency guide](../docs/react-consumers.md) for the public A2UI imports, separate Zod majors and required npm/pnpm Markdown pin. @@ -355,7 +355,7 @@ Three owners each decide once. Core owns the neutral value and imports no delive ### The tic-tac-toe example -`examples/tic-tac-toe` is a game between a person and an assistant, generated with `saga init --a2ui-stack react-mcp --server hono` and built on the React stack. It shows a view adding a message to the conversation (`host.conversation.appendMessage`) and operations without a view, together: `new_game` and `show_game` open the board, and `get_game` and `play_move` return the game as data. The person's click and the assistant's tool call reach the same `play_move`, so one server-side rule set (`server/rules.ts`) and one atomic check-and-apply (`server/game-store.ts`) govern both. Its [README](../examples/tic-tac-toe/README.md) lists the files; the guide [Build tic-tac-toe with React](build-tic-tac-toe-with-react.md) is being written, and its opening is the first part. +`examples/tic-tac-toe` is a game between a person and an assistant, generated with `saga init --a2ui-stack react-mcp --server hono` and built on the React stack. It shows a view adding a message to the conversation (`host.conversation.appendMessage`) and operations without a view, together: `new_game` and `show_game` open the board, and `get_game` and `play_move` return the game as data. The person's click and the assistant's tool call reach the same `play_move`, so one server-side rule set (`server/rules.ts`) and one atomic check-and-apply (`server/game-store.ts`) govern both. Its [README](../examples/tic-tac-toe/README.md) lists the files; the guide [Build tic-tac-toe with React](build-tic-tac-toe-with-react.md) walks through it. ```sh npm run build:tic-tac-toe # apps/lit-host/dist/tic-tac-toe and tic-tac-toe-guided (ignored; part of npm run build) @@ -369,6 +369,7 @@ cd apps/lit-host && npx playwright test e2e/tic-tac-toe.spec.ts # the whole su - **The board's controller** (`apps/lit-host/e2e/tic-tac-toe-controller.test.ts`): with the server's replies held and released by the test, a recovery read turns the squares off until it settles; a read for a game that New game has since replaced neither disables the new game's squares nor changes the board when it settles late; and two reads of one game keep the squares off until both have settled. - **The example** (`scripts/tic-tac-toe.test.ts`): it keeps the template's build files byte for byte and its dependencies, removes the sign-in files instead of stubbing them, imports no `@modelcontextprotocol/ext-apps` module and no `@a2ui/*` package, builds from packed tarballs with no server code in the page, starts with `node dist/server.mjs`, and takes the guided change as a patch to `src/surface.ts` and `src/catalog.tsx` only. - **The board in Chromium** (`apps/lit-host/e2e/tic-tac-toe.spec.ts`, 23 tests, on the local host fixture): clicks and keyboard moves; the context update with the game in `structuredContent`, then the move message that names the game and draws no board; the message still sent with the context capability withheld; the instruction when no message is accepted; a winning and a drawing X move, each recording one result message with no context and no wait; an O move made through the server and picked up by the wait; stale and repeated moves refused with no effect; New game unaffected by a move request still in flight; a failed append that settles after New game showing nothing on the new game; the wait's limit and its button; an expired game; a lost reply recovered by reading the game, and that read, held while New game is pressed, leaving the new board playable before and after it settles; the wait ending with the view; and the guided change's move list. No test asserts which square an assistant chooses. The fixture gained `hold-then-refuse`, which holds a conversation request and refuses it when released. +- **The guide** (`scripts/tic-tac-toe-document.test.ts`): every command and its order, the scripts, workspace, specification and test they name, the starter the `init` command creates, each excerpt of source in order against the file it comes from, the guided-change patch line for line, the status lines and messages the guide quotes, the operations table against what the server declares and the board calls, every link, anchor, image and diagram source, the hosts section's refusal to claim a host run (and the host compatibility records it rests on), and the labels of the closing arrangements. `scripts/guide.test.ts` checks the setup guide and the Lit track in the same way. - **Limits.** Local source and Chromium evidence. Claude, ChatGPT and Alexa+ have not been run against the game. The wait's cadence and limit (1.5 s, 40 reads) are proposals that host runs settle. A server registers a view once per address, so `new_game` and `show_game` declare the same page under two addresses (`new-game` and `show-game`) rather than one shared view. ### Consumer boundary scan and package directions diff --git a/docs/getting-started.md b/docs/getting-started.md index d2d8a019..8636507f 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -6,11 +6,29 @@ React and Lit are both supported renderer choices. Install the published MCP sta ![Choose a published Lit or React stack, or a source-based Lit or React starter, then supply application behavior](../diagrams/saga-setup-paths.svg) +## Choose a track + +Setup is the same for every app. What you build next is not. Each track builds one application from start to finish; neither is a ranking of renderers, so choose by the framework you will write in. + +| Track | You build | Read it for | +| --- | --- | --- | +| [Build tic-tac-toe with React](build-tic-tac-toe-with-react.md) | A game played by a person and an AI assistant, in the chat | The shortest path to what saga does. It reads in about ten minutes and ends with what else the design allows. | +| [Build the notes application with Lit](build-notes-with-lit.md) | A list of notes with a protected Archive action | Composing a view with the Lit stack, identity, evidence boundaries, and a lead into the [action guide](actions.md). | + +If you are unsure, read the React track first. Its ideas carry over: the layout, the operations and the conversation message do not depend on the renderer. + +## Prerequisites + +- **Node and npm.** An application that installs the published packages needs Node 22.12.0 or newer. This repository's checkout needs 22.23.3 or newer (`.nvmrc`). +- **A bundler that handles TypeScript source.** The packages export source rather than precompiled JavaScript. +- **A checkout of this repository** for `saga init`, the candidate tarballs and the examples. Run `npm ci` in its root. +- **An MCP host is optional while you build.** The demonstration and the examples have local stand-ins for one. Connecting a real host comes afterwards and is covered by each track and by [host compatibility](host-compatibility.md). + ## Install a published stack The seven-package release set is published at `0.7.7-rc.1` under `next`, including both renderers and both MCP stacks. Pin the exact version rather than relying on mutable tags. The [release receipt](https://github.com/MachineWisdomAI/saga-a2ui/releases/tag/v0.7.7-rc.1) records anonymous installation, integrity, compilation and build checks. The receipt also identifies the package and source revisions used here. See [public installation](public-install.md) for registry checks and package selection. -Use Node 22.12.0 or newer and a bundler that handles TypeScript source. The packages export source rather than precompiled JavaScript. This registry path configures an existing application; the runnable local demo below is the quickest way to see a complete view before integrating your own backend. In your application's root `package.json`, merge this override with your existing settings before installing: +Use Node 22.12.0 or newer and a bundler that handles TypeScript source. The packages export source rather than precompiled JavaScript. This registry path configures an existing application; the local demonstration in the [Lit track](build-notes-with-lit.md#start-from-the-demo) is the quickest way to see a complete view before integrating your own backend. In your application's root `package.json`, merge this override with your existing settings before installing: ```json {"overrides": {"@a2ui/markdown-it": "0.2.1"}} @@ -36,26 +54,6 @@ Use the main entry for an application-owned catalog, `/faithful-card` for the op The [React integration guide](react-consumers.md#open-a-typed-surface) shows how to open, mount, update and dispose a typed surface in an existing React application. For MCP delivery, the application still needs its own catalog, result handling and backend; this install section does not create those files or promise a running host connection. The [view-composition example](../examples/pack1-consumer/) shows a complete application using the stack. It is a development example, not a React `saga init` template. -## Start from the demo - -The demonstration is a synthetic, local-only application of one qualified pair, built from packed candidate tarballs outside the repository. It is the fastest way to see the workflow: browse, descriptions at both content levels, protected Play, and the notes actions. It is **not a starter** and not a release download; no hosted copy exists. - -Use a source checkout with Node 22.23.3 or newer (the repository toolchain minimum). From its root: - -```sh repository -npm ci -npm run build:demo -``` - -```sh demo -npm install --omit=dev -npm start -``` - -The first block writes `apps/lit-host/dist/demo`; run the second inside it. Open the `Open the demo host page:` address it prints. The generated demo itself requires Node 22.12.0 or newer. Choose Alice in the local host page, open Notes, and press Archive on a note. The row reports the archive result. End the notes session and try another Archive: the prerequisite refuses and no archive request is sent. These are synthetic local credentials, not a production identity flow. - -See the [demonstration README](../demo/lit-node/README.md) for the controls, the environment variables and what each shows. - ## Which packages do what Package names are flat under `@saga-a2ui`. There are no aggregate packages: no `@saga-a2ui/genui`, `protocol`, `transport` or `tools`. Groups exist only in the documentation ([package layout](../packages/README.md)). @@ -64,7 +62,7 @@ Package names are flat under `@saga-a2ui`. There are no aggregate packages: no ` | --- | --- | --- | | Core | `@saga-a2ui/core` | Implemented. Renderer-independent action contract (`/actions`), the A2UI v0.9 message session (`/protocols/a2ui/v0_9`) and the Zod 3 alias for catalog schemas (`/protocols/a2ui/zod3`). | | Delivery | `@saga-a2ui/mcp` | Implemented. `/server` (protected operations, verified request identity, one request lifetime), `/view` (the view's connection) and `/policy`. | -| GenUI renderers | `@saga-a2ui/lit`, `@saga-a2ui/react` | Both implement typed A2UI surfaces, action controls and optional FaithfulCard rendering through their own framework integration. A `@saga-a2ui/lynx` package does not exist; Lynx delivery is retired (see below). | +| GenUI renderers | `@saga-a2ui/lit`, `@saga-a2ui/react` | Both implement typed A2UI surfaces, action controls and optional FaithfulCard rendering through their own framework integration. A `@saga-a2ui/lynx` package does not exist; Lynx delivery is retired (see [what is implemented](build-notes-with-lit.md#what-is-implemented-and-what-is-not)). | | Stacks | `@saga-a2ui/lit-mcp-stack`, `@saga-a2ui/react-mcp-stack` | Implemented. A stack connects one renderer to MCP delivery, so an application installs one stack plus its framework peers and imports from it only: `startA2uiView` for an application's own catalog, and `startFaithfulCardApp` from the `/faithful-card` subpath for a card application. Both stacks are published at `0.7.7-rc.1`. | | Components | `@saga-a2ui/components` | Implemented, optional. The passive FaithfulCard slice. Both stacks install it transitively; an own-catalog application need not import it, and its main-entry browser bundle excludes it. | | Tooling | none | The source CLI implements `saga init`, `saga demo` and optional `saga publish` preparation/handoff commands. The `@saga-a2ui/publish` package does not exist; the [publication tooling](../scripts/publish/README.md) runs from this checkout and does not submit or publish an application. | @@ -99,7 +97,7 @@ npx tsx scripts/saga.ts init --a2ui-stack lit-mcp --server hono ./my-app npx tsx scripts/saga.ts init --a2ui-stack react-mcp --server hono ./my-react-app ``` -`--a2ui-stack` selects the renderer/delivery combination; `--server` selects the server framework. The commands create `my-app` (a Lit view) and `my-react-app` (a React view on `@saga-a2ui/react-mcp-stack` with React 19.3.1); both serve it from Hono and install nothing. The rest of this guide shows the Lit project, and what differs for React is noted where it matters. Use Node 22.23.3 or newer for this source checkout. Keep this checkout unchanged while exporting the project and packing its dependencies so their candidate identities match. +`--a2ui-stack` selects the renderer/delivery combination; `--server` selects the server framework. The commands create `my-app` (a Lit view) and `my-react-app` (a React view on `@saga-a2ui/react-mcp-stack` with React 19.3.1); both serve it from Hono and install nothing. The steps below use the Lit project, and what differs for React is noted where it matters. The [React track](build-tic-tac-toe-with-react.md) builds on the output of the second command. Use Node 22.23.3 or newer for this source checkout. Keep this checkout unchanged while exporting the project and packing its dependencies so their candidate identities match. ### Install the packages from a candidate @@ -129,7 +127,7 @@ npm run build npm start ``` -`npm run typecheck` checks the view and the server. `npm start` prints two endpoint addresses: `/status/mcp` is public and reads no credential; `/account/mcp` is protected. **The protected endpoint refuses every credential until you replace `server/verifier.ts`.** The starter ships no identity provider and no account, sample data or test verifier. These addresses are MCP endpoints, not browser pages. The server is ready when it prints them. Connect an MCP Apps-capable host to the public `/status/mcp` endpoint and invoke its `get_status` tool to request the starter view. A remote host needs a reachable application address; starting a loopback server does not deploy it. For an immediately viewable local application without host setup, use the demo above. The [template README](../templates/lit-hono/README.md) lists the files that are yours. +`npm run typecheck` checks the view and the server. `npm start` prints two endpoint addresses: `/status/mcp` is public and reads no credential; `/account/mcp` is protected. **The protected endpoint refuses every credential until you replace `server/verifier.ts`.** The starter ships no identity provider and no account, sample data or test verifier. These addresses are MCP endpoints, not browser pages. The server is ready when it prints them. Connect an MCP Apps-capable host to the public `/status/mcp` endpoint and invoke its `get_status` tool to request the starter view. A remote host needs a reachable application address; starting a loopback server does not deploy it. For an immediately viewable local application without host setup, use the [demonstration](build-notes-with-lit.md#start-from-the-demo). The [template README](../templates/lit-hono/README.md) lists the files that are yours. ### HTTP defaults @@ -196,128 +194,21 @@ npx tsx scripts/starter-snippets.ts --template lit-express Additional server slices add their own recipe by joining the qualified inventory and regenerating the recipes (`npm run guide:recipes`); `scripts/guide.test.ts` fails until they do. The Hapi recipe traces its route authentication through the request factory and transport to the verified-identity hook. Each application supplies its verifier and authorization policy. -## Compose a view with a stack - -An application installs one stack plus its framework peers and writes only what is its own. The stack connects to the host, holds a result that arrives before the view is ready (the latest wins), gives each result to the application, and ends the view once, before the connection ends, when the host tears it down, `dispose()` runs or the page goes away. A raw `pagehide` listener is not needed for any of that; the application keeps one only for behavior that is its own. - -The Lit starter's composition is this. It is excerpted line for line from `templates/lit-node/src/view.ts`, and `scripts/guide.test.ts` fails when a line stops being there or when the call omits an option `createLitActions` requires. The layout is in `surface.ts` and the catalog in `catalog.ts`. `requireElement`, `say`, `show` and `retire` are the application's own helpers in the same file: what a result means and what ending the view takes with it. `retire` ends only what the application owns. The surface owns the discovery and the bindings made in `attach`, and ends them when it leaves the page, before the stack calls `onTeardown`. - -```ts -import { - ActionButton, - createLitActions, - startA2uiView, -} from '@saga-a2ui/lit-mcp-stack'; - -const view = startA2uiView({ - container: requireElement('starter-app'), - layout: starterSurface, - catalog: starterCatalog, - identity: { name: 'saga-starter-view', version: '0.0.0' }, - actions: createLitActions({ - attach: (surfaceView) => { - surfaceView.watchById( - CLEAR_ID, - ActionButton, - (handle) => void handle.bindAction('activate', actions.clearNote), - ); - }, - onSetupError: () => say(PROBLEM_TEXT), - }), - onToolResult: (result, running) => show(result, running.surface), - onTeardown: retire, -}); -await view.ready; -``` - -`attach` runs once for each presented surface and binds the application's action to the control it finds. `onSetupError` is required: it receives the error of a setup that throws, and the page shows only the safe fallback while the text goes to your callback. In `show`, `surface.update({ headline })` validates the data against the layout and applies it in place: the mounted controls, their bindings and focus survive, and a rejected update changes nothing. A card application states a validated definition instead (`FaithfulCardApp.define(...)`) and starts it with `startFaithfulCardApp` from `@saga-a2ui/lit-mcp-stack/faithful-card` or `@saga-a2ui/react-mcp-stack/faithful-card`. Neither stack asks the application to write A2UI messages, to import the MCP Apps bridge, or to order the handshake; the repository's consumer boundary scan (`scripts/consumer-boundary.test.ts`) fails a template, example or view module that does. The catalog is written against the stack's own exports too (`A2uiLitElement`, `basicCatalog`, `Catalog`, `CommonSchemas`, `z`), so an application that installs the stack names no upstream renderer or schema package. - -A stack's installed tree includes `@saga-a2ui/components` and `markdown-it` as its declared dependencies; the emitted bundle of an application that uses only the main entry does not (the card entry is where they are used). The [React consumer dependency guide](react-consumers.md) has the React recipe and the registry-versus-candidate distinction. - -## Content policy - -A view that shows supplied Markdown (the optional FaithfulCard) does so under one **trusted content policy** that the application chooses in its own server code. Neither the model nor the supplied content sets it. - -- **Standard** permits validated HTTPS links through the host, and images from explicitly allowed HTTPS hosts. -- **Strict** keeps every link inert, shows blocked fragments as accessible placeholders (or inert text) that the reader can reveal as text, and loads a permitted image only after the reader chooses **Show images**. Consent is revoked by a new result or by closing the view. - -Both keep ordinary formatting and both retain server authorization and data-sharing checks. The starter itself shows no Markdown: its view is served with the closed policy `CLOSED_VIEW_CSP` from `@saga-a2ui/mcp/policy`, which allows no network or external resource. Ansari's branding and presentation rules stay with Ansari. - -### Rebuild and resource matching - -A view is served as an MCP App resource whose address is `ui://views/.<32-character-sha256-prefix>.html`, the SHA-256 of the exact bytes served. A view that embeds a policy has an address that depends on that policy, so **changing the policy or rebuilding the view changes the address**. Rebuild the view from the installed packages, restart the server so it serves the rebuilt view, and pair a server only with the view built beside it. A view that meets a result it does not understand shows that the view and its server are from different builds instead of rendering it. `npm run candidate:identity` prints the identities for a given policy. - -## Identity and the evidence behind it - -The application authenticates the request and its server decides what the account may see; SAGA carries the verified result. In the starter, `server/listener.ts` verifies the request, hands the result to the transport, and the operation reads it with `identityOf`. No tool argument can name an account, a prior call authorizes nothing, and two overlapping requests each see their own identity. - -**Synthetic versus real identity.** The demonstration's sign-in choices are fixed public development credentials printed in the source (`apps/lit-host/src/mcp/dev-auth.ts`). They prove nothing about a person, the page and the process say so, and the process refuses `NODE_ENV=production`. They are not a model for production. A real host uses its supported authentication through your own `OAuthTokenVerifier`, and a loopback run says nothing about it. - -**Evidence is several different things.** A claim is only as wide as the check that supports it: - -| Evidence | It shows | It does not show | -| --- | --- | --- | -| Invocation | An action ran against an instance, with its prerequisites, hooks and outcome | That the control was visible, or that a host delivers the gesture | -| Rendering | A view displayed content under a policy in the local host fixture | Rendering in ChatGPT, Claude or Alexa+ | -| Authentication | A request with an accepted, refused or absent credential was admitted or refused | An OAuth or DCR flow, or any real identity provider | -| Audio | The local audio clock advanced | Audio in a remote host | - -Named-host receipts exist for a few specific flows and are recorded in [host compatibility observations](host-compatibility.md). The starter, the demonstration and this guide have not been run in an actual assistant host. - -## Actions - -The attachment above is the whole of what this guide teaches. [Change a component and trace allowed and denied requests](actions.md) is where the details belong: it changes a component and its action, then traces input, prerequisites, verified identity, authorization, delivery and cleanup, with every case run. This section is the short version. - -The verified **non-playback** example is the notes application: `examples/pack1-consumer/src/notes-actions.ts` defines Archive once and `notes-view.ts` attaches it to each rendered row. The demonstration serves it behind the starter's own authentication, and its README shows each outcome: - -- **Asynchronous prerequisites.** Checking that the session is still valid is a prerequisite that runs before the handler. If it refuses, no protected request is sent. -- **Outcome hooks.** Success, failure and cancellation each have a hook that reports what that outcome establishes. A timeout or an unreadable reply is *unknown*, not "nothing happened". -- **Application resource ownership.** A hold the application takes for a press is given back on every non-success end and handed to its owner only after the server confirms. The application owns it, not the binding. - -Two limits matter before you rely on a client-side control: - -- **A client check does not authorize a protected server operation.** The server decides again for every protected request; a prerequisite only saves a request. -- **Cancellation does not undo remote effects.** Closing a view stops the client waiting. It does not establish that the host forwarded cancellation or that the server stopped, and an applied write stays applied. Uncertain writes are not retried automatically. - -Defaults, kept separate: action work 30 seconds and each hook or cleanup 5 seconds; server operations 3 seconds inside a 10-second request budget that starts before authentication. Invalid explicit settings fail, and a stage never resets its parent's budget. - -## What is implemented and what is not - -| Area | Implemented, qualified in source | Not done or not shown | -| --- | --- | --- | -| Shared action contract | `@saga-a2ui/core/actions`, with the opt-in Lit and React `ActionButton` controls; Play runs on it | A registry or handler-name table, which do not exist | -| Lit | Published renderer and MCP stack: typed surfaces, actions and FaithfulCard applications; packed-consumer checks cover the starter, demonstration and notes example | Starter and local fixture results do not establish every application or host combination; Windows is not qualified here | -| React | Published renderer and MCP stack: typed surfaces, actions and FaithfulCard applications; packed-consumer and browser checks cover own-catalog notes and branded answer cards. Bounded notes observations in Claude/ChatGPT are recorded in [host compatibility](host-compatibility.md) | One generated React starter, `react-mcp` on Hono; no other React server pair and no React demonstration. Qualification of a particular application and host remains specific to its built artifact; no complete host matrix is claimed | -| Server targets | `node`, `express`, `hono`, `fastify`, `hapi` qualified with `lit-mcp`; `hono` qualified with `react-mcp` | No full matrix; `saga init` offers those six pairs; the standalone demonstration is the Lit and Node application only | -| Lynx | Nothing. Lynx delivery was retired on 2026-10-19; `apps/lynx-fixture` and the Lynx proposals in the Blueprints are **historical** designs and evidence, with no delivery promise, no recipe and no adapter | Any current Lynx setup | - -A renderer is qualified by running its own lifecycle, not by declaring that it could: a type declaration that compiles is not a working setup claim, and no renderer is forced through a Lit-specific lifecycle (custom elements and `createA2uiAppElement`). React attaches through its own provider and hook; the shared contract is renderer-neutral. - - -### React applications - -For MCP delivery, use `@saga-a2ui/react-mcp-stack` and its exports, as in the [complete React application](react-consumers.md#follow-a-complete-react-application). If you deliberately compose below the stack, use SAGA's React A2UI entry point for public surface and catalog APIs. Do not add direct dependencies on `@a2ui/react` or `@a2ui/web_core`, or import their catalog or surface APIs yourself: - -```ts -import {A2uiSurface, Catalog, type ReactCatalogComponent} from '@saga-a2ui/react/a2ui'; -``` - -`@saga-a2ui/react/a2ui` is the supported surface. Keep React and React DOM as your declared peers, pin `@a2ui/markdown-it` 0.2.1 at the consumer root, and keep the separate Zod majors apart: [the React consumer dependency guide](react-consumers.md) is the recipe. Any later React pair exposure keeps its own renderer qualification. - - ## Troubleshooting setup **Candidate packages cannot be found on npm.** The Lit starter pins commit-specific candidate versions. Build and install the matched tarballs, rather than replacing those pins with independently selected public packages. The CLI's generic `npm install` next step does not do this for you. **The protected endpoint refuses your credentials.** Replace `server/verifier.ts` with your application's verifier and supply its authorization policy. The clean starter deliberately has no test account or identity provider. The public `/status/mcp` endpoint can work while `/account/mcp` correctly refuses access. -**The demo directory does not exist.** Run `npm run build:demo` in the source checkout first. It produces the standalone directory whose own `npm start` serves the demonstration. The standalone demo supports Node only; the other server targets are starter templates. +**The demo directory does not exist.** The [demonstration](build-notes-with-lit.md#start-from-the-demo) is built, not downloaded. Run `npm run build:demo` in the source checkout first. It produces the standalone directory whose own `npm start` serves the demonstration. The standalone demo supports Node only; the other server targets are starter templates. **A React starter command on another server is refused.** The React stack is published and `saga init` offers `react-mcp` on `hono` only. For another server, integrate the stack in a React application; do not replace its lifecycle with a Lit-specific lifecycle or follow the retired historical Lynx fixture as a setup path. ## Where to go next +- Choose a track: [Build tic-tac-toe with React](build-tic-tac-toe-with-react.md) or [Build the notes application with Lit](build-notes-with-lit.md). - The attach recipes: [Attach SAGA to an existing backend](attach-to-existing-backend.md). - The command that creates a project, and its safety rules: `scripts/saga.ts` and the "Creating a project with `saga init` (CLI1)" section of the [development reference](development-reference.md#creating-a-project-with-saga-init-cli1). - The action contract in depth: the [action binding design](../blueprints/modules/action_binding.blueprint.md) and [The action contract across the current codebase (ACT7)](development-reference.md#the-action-contract-across-the-current-codebase-act7). -- The design this guide implements: the [application setup Blueprint](../blueprints/features/application_setup.blueprint.md). +- What is implemented and what is not, and the evidence behind each claim: [the Lit track](build-notes-with-lit.md#what-is-implemented-and-what-is-not). +- The design this guide implements: the [application setup Blueprint](../blueprints/features/application_setup.blueprint.md) and the [getting-started tracks Blueprint](../blueprints/features/getting_started_tracks.blueprint.md). diff --git a/docs/public-install.md b/docs/public-install.md index 68a46ece..a414b880 100644 --- a/docs/public-install.md +++ b/docs/public-install.md @@ -59,9 +59,9 @@ Applications that intentionally compose lower-level SDK APIs may install the pac ## Run a demo or use a starter -[Getting started](getting-started.md) runs the synthetic demo, creates a Lit starter or the React starter on Hono, or attaches SAGA to an existing backend. Those source-tooling workflows build matched candidate tarballs from a checkout so contributors can test unpublished changes. Candidates have versions such as `0.0.0-candidate.g<12 hex>` and are distinct from public npm releases; see [candidate installation, updates and rollback](candidate-update-and-rollback.md). The source CLI, root workspace and example applications are not npm packages. +[Getting started](getting-started.md) creates a Lit starter or the React starter on Hono, or attaches SAGA to an existing backend, and its two tracks build an application ([React](build-tic-tac-toe-with-react.md), [Lit](build-notes-with-lit.md), which also runs the synthetic demo). Those source-tooling workflows build matched candidate tarballs from a checkout so contributors can test unpublished changes. Candidates have versions such as `0.0.0-candidate.g<12 hex>` and are distinct from public npm releases; see [candidate installation, updates and rollback](candidate-update-and-rollback.md). The source CLI, root workspace and example applications are not npm packages. -Continue with [Getting started](getting-started.md#compose-a-view-with-a-stack) to connect a view, then the [action guide](actions.md) to trace a complete interaction. [Host compatibility](host-compatibility.md) records the flows observed in ChatGPT, Claude and Alexa+. Installing a package does not deploy an application or establish acceptance in an assistant host. +Continue with [the Lit track](build-notes-with-lit.md#compose-a-view-with-a-stack) or the [React track](build-tic-tac-toe-with-react.md) to connect a view, then the [action guide](actions.md) to trace a complete interaction. [Host compatibility](host-compatibility.md) records the flows observed in ChatGPT, Claude and Alexa+. Installing a package does not deploy an application or establish acceptance in an assistant host. ## Maintainer publication diff --git a/examples/tic-tac-toe/README.md b/examples/tic-tac-toe/README.md index 7d4d6b24..66b4640d 100644 --- a/examples/tic-tac-toe/README.md +++ b/examples/tic-tac-toe/README.md @@ -35,4 +35,4 @@ node apps/lit-host/dist/tic-tac-toe/server.mjs - `scripts/tic-tac-toe-rules.test.ts`: the rules and the store (every line, the draw, turn order, refusals that leave a game unchanged, one of two requests with the same move number). - `scripts/tic-tac-toe.test.ts`: the example keeps the template, imports no MCP Apps module, builds from the packed packages, starts, and takes the guided change. - `apps/lit-host/e2e/tic-tac-toe.spec.ts`: the board in Chromium on the local MCP Apps host fixture. No test asserts which square an assistant chooses. -- `scripts/tic-tac-toe-document.test.ts`: the guide's opening against this example. +- `scripts/tic-tac-toe-document.test.ts`: the guide's commands, quoted source, sentences and links against this example. diff --git a/scripts/guide.test.ts b/scripts/guide.test.ts index 43364a33..1da1faf2 100644 --- a/scripts/guide.test.ts +++ b/scripts/guide.test.ts @@ -20,13 +20,17 @@ import { templateFiles, } from './starter-template.js'; -// The setup guide (docs/getting-started.md) and its generated recipes -// (docs/attach-to-existing-backend.md) as a developer uses them: every command a reader is told to -// run, every snippet they are told to copy and every name they are told to install is checked -// against the tool, template or package it names. Prose is not parsed for meaning; what is checked -// is what a reader would type, import or follow. +// The setup guide (docs/getting-started.md), the Lit track it leads to (docs/build-notes-with-lit.md) +// and the generated recipes (docs/attach-to-existing-backend.md) as a developer uses them: every +// command a reader is told to run, every snippet they are told to copy and every name they are told +// to install is checked against the tool, template or package it names. Prose is not parsed for +// meaning; what is checked is what a reader would type, import or follow. The setup guide keeps what +// every app needs; the Lit composition, evidence and actions overview moved to the Lit track, and +// its assertions moved with it. The React track is checked by tic-tac-toe-document.test.ts. const GUIDE = 'docs/getting-started.md'; +const LIT_TRACK = 'docs/build-notes-with-lit.md'; +const REACT_TRACK = 'docs/build-tic-tac-toe-with-react.md'; const read = (file: string): string => fs.readFileSync(path.join(repoRoot, file), 'utf8'); /** The guide only runs `init`; a demo build here would be a defect, so asking for one fails the test. */ @@ -77,9 +81,12 @@ const commandsOf = (markdown: string, kind: string): string[] => .filter((line) => line !== ''); const guide = read(GUIDE); +const litTrack = read(LIT_TRACK); const recipes = read(RECIPES_FILE); const documents: Record = { [GUIDE]: guide, + [LIT_TRACK]: litTrack, + [REACT_TRACK]: read(REACT_TRACK), [RECIPES_FILE]: recipes, }; @@ -320,7 +327,7 @@ describe('the commands the guide tells a reader to type', () => { it('run in the demo download only its declared scripts, without requiring a build', () => { const scripts = DOWNLOAD_SCRIPTS; - const shown = commandsOf(guide, 'demo'); + const shown = commandsOf(litTrack, 'demo'); expect(shown.length).toBeGreaterThan(0); for (const line of shown) { const script = /^npm (?:run )?(start|[\w:.-]+)$/.exec(line)?.[1]; @@ -386,9 +393,9 @@ describe('the names and imports the guide uses', () => { /from ['"]@a2ui\/react|from ['"]@a2ui\/web_core.*react/i, ); } - const reactSection = guide.slice( - guide.indexOf(''), - guide.indexOf(''), + const reactSection = litTrack.slice( + litTrack.indexOf(''), + litTrack.indexOf(''), ); expect(reactSection).toContain('`@saga-a2ui/react/a2ui`'); expect(reactSection).not.toMatch(/import .* from '@a2ui\/react'/); @@ -433,42 +440,113 @@ describe('links in the guide and the recipes', () => { }); }); -describe('what the guide must explain', () => { - const headings = guide +const headingsOf = (markdown: string): string[] => + markdown .split('\n') .filter((line) => /^#{2,3} /.test(line)) .map((line) => line.replace(/^#+ /, '')); - it('has a section for each part of the setup the issue names', () => { +describe('what the setup guide must explain', () => { + const headings = headingsOf(guide); + + it('has a section for each part of the setup that every app needs', () => { for (const required of [ + 'Choose a track', + 'Prerequisites', + 'Install a published stack', 'Which packages do what', 'Component, definition, template and instance', - 'Start from the demo', 'Create a new project', + 'HTTP defaults', + 'Unqualified pairs are refused', 'Attach to an existing backend', + 'Troubleshooting setup', + 'Where to go next', + ]) { + expect(headings, required).toContain(required); + } + }); + + it('keeps only what every app needs: the Lit composition, evidence and actions overview are in the Lit track', () => { + for (const moved of [ + 'Start from the demo', + 'Compose a view with a stack', 'Content policy', 'Identity and the evidence behind it', 'Actions', 'What is implemented and what is not', - 'Troubleshooting setup', + ]) { + expect(headings, `${moved} is not in the setup guide`).not.toContain( + moved, + ); + expect(headingsOf(litTrack), `${moved} is in the Lit track`).toContain( + moved, + ); + } + expect(guide).not.toContain('startA2uiView({'); + }); + + it('offers the two tracks, and each of them exists and says it comes after setup', () => { + const choice = guide.slice( + guide.indexOf('## Choose a track'), + guide.indexOf('## Prerequisites'), + ); + expect(choice).toContain('(build-tic-tac-toe-with-react.md)'); + expect(choice).toContain('(build-notes-with-lit.md)'); + expect(choice).toContain('(actions.md)'); + expect(headingsOf(read(REACT_TRACK))).toContain('Where to go next'); + expect(litTrack).toContain('(getting-started.md)'); + }); +}); + +describe('what the Lit track must explain', () => { + const headings = headingsOf(litTrack); + + it('has a section for each part of the Lit composition and actions overview', () => { + for (const required of [ + 'Start from the demo', + 'Compose a view with a stack', + 'Content policy', + 'Identity and the evidence behind it', + 'Actions', + 'What is implemented and what is not', + 'Where to go next', ]) { expect(headings, required).toContain(required); } }); - it('keeps Lynx as history, with no setup claim, and does not route another renderer through the Lit lifecycle', () => { + it('leads into the action guide, as the next step of the track', () => { + expect(litTrack).toContain('(actions.md)'); + const next = litTrack.slice(litTrack.indexOf('## Where to go next')); + expect(next).toContain('(actions.md)'); + expect(next).toContain('(getting-started.md)'); + expect(next).toContain('(build-tic-tac-toe-with-react.md)'); + }); + + it('points to the setup guide and the other track from its opening', () => { + const opening = litTrack.slice(0, litTrack.indexOf('\n## ')); + expect(opening).toContain('(getting-started.md)'); + expect(opening).toContain('(build-tic-tac-toe-with-react.md)'); + }); +}); + +describe('the evidence rules in the guides', () => { + it('keep Lynx as history, with no setup claim, and does not route another renderer through the Lit lifecycle', () => { // Prose only: the one command that installs the retained fixture's own lockfile is a command, not a claim. - const prose = guide.replace(/```[\s\S]*?```/g, ''); - const lynx = prose.split('\n').filter((line) => /lynx/i.test(line)); - expect(lynx.length).toBeGreaterThan(0); - for (const line of lynx) - expect(line).toMatch(/retired|history|historical/i); - expect(guide).toMatch( + for (const document of [guide, litTrack]) { + const prose = document.replace(/```[\s\S]*?```/g, ''); + const lynx = prose.split('\n').filter((line) => /lynx/i.test(line)); + expect(lynx.length).toBeGreaterThan(0); + for (const line of lynx) + expect(line).toMatch(/retired|history|historical/i); + } + expect(litTrack).toMatch( /not a Lit lifecycle|no Lit-specific lifecycle|Lit-specific lifecycle/, ); }); - it('quotes identifiers the notes example and the starter really contain', () => { + it('quote identifiers the notes example and the starter really contain', () => { const files = { 'examples/pack1-consumer/src/notes-surface.ts': [ "id: 'row-archive'", @@ -500,16 +578,18 @@ describe('what the guide must explain', () => { `${file} contains ${needle}`, ).toContain(needle.replace(/\s+/g, '')); } + // What stayed in the setup guide, and what moved with the Lit material. for (const quoted of [ 'row-archive', "{ componentId: 'note-row', path: '/notes' }", '/status/mcp', '/account/mcp', - 'CLOSED_VIEW_CSP', - 'identityOf', ]) { expect(guide, quoted).toContain(quoted); } + for (const quoted of ['CLOSED_VIEW_CSP', 'identityOf']) { + expect(litTrack, quoted).toContain(quoted); + } expect(read('templates/lit-node/server/listener.ts')).toContain( "'/status/mcp'", ); @@ -519,7 +599,7 @@ describe('what the guide must explain', () => { }); describe('the quoted stack composition', () => { - const composition = blocks(guide).find((block) => + const composition = blocks(litTrack).find((block) => block.lines.some((line) => line.includes('startA2uiView({')), ); const significant = (lines: readonly string[]): string[] => diff --git a/scripts/tic-tac-toe-document.test.ts b/scripts/tic-tac-toe-document.test.ts index b76507c2..5a270eca 100644 --- a/scripts/tic-tac-toe-document.test.ts +++ b/scripts/tic-tac-toe-document.test.ts @@ -1,25 +1,59 @@ import fs from 'node:fs'; +import os from 'node:os'; import path from 'node:path'; -import { describe, expect, it } from 'vitest'; +import { afterAll, describe, expect, it } from 'vitest'; import { repoRoot } from './consumer-boundary.js'; -import { CONVERSATION, VIEW_TEXT } from '../examples/tic-tac-toe/src/text.js'; +import { runCli, SUPPORTED_PAIRS } from './saga.js'; +import { templateDirectory, templateFiles } from './starter-template.js'; +import { + GET_GAME_TOOL, + NEW_GAME_TOOL, + PLAY_MOVE_TOOL, + SHOW_GAME_TOOL, +} from '../examples/tic-tac-toe/src/contract.js'; +import { DEFAULT_TTL_MS } from '../examples/tic-tac-toe/server/game-store.js'; +import { + CONVERSATION, + moveText, + VIEW_TEXT, +} from '../examples/tic-tac-toe/src/text.js'; -// The opening of docs/build-tic-tac-toe-with-react.md: the finished game and the first click. Every -// command, every sentence it quotes from the game and every link and image is checked against the -// example, so the guide cannot describe a game that is not there. The rest of the guide is written -// after the owner reviews this opening. +// docs/build-tic-tac-toe-with-react.md, the whole guide. Every command, every piece of source it +// quotes, every sentence it quotes from the game, every claim it makes about a host and every +// link, image and diagram is checked against the example (or the document that backs the claim), +// so the guide cannot describe a game that is not there. The setup guide and the Lit track are +// checked by guide.test.ts. The guide never claims a host run: its hosts section is checked for +// saying that none has happened. const GUIDE = 'docs/build-tic-tac-toe-with-react.md'; -const guide = fs.readFileSync(path.join(repoRoot, GUIDE), 'utf8'); -const rootManifest = JSON.parse( - fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8'), -) as { scripts: Record }; +const EXAMPLE = 'examples/tic-tac-toe'; +const read = (file: string): string => + fs.readFileSync(path.join(repoRoot, file), 'utf8'); +const guide = read(GUIDE); +const example = (file: string): string => read(`${EXAMPLE}/${file}`); +const rootManifest = JSON.parse(read('package.json')) as { + scripts: Record; +}; +const work = fs.mkdtempSync(path.join(os.tmpdir(), 'saga-ttt-guide-')); +afterAll(() => fs.rmSync(work, { recursive: true, force: true })); -/** The text of every fenced block that starts with this info string. */ -const blocks = (info: string): string[] => - [...guide.matchAll(/^```([^\n]*)\n([\s\S]*?)^```/gm)] - .filter((match) => match[1] === info) - .map((match) => match[2]!); +interface Block { + readonly info: string; + readonly lines: readonly string[]; +} + +/** Every fenced block of a document, with its info string. */ +const fenced = (markdown: string): Block[] => + [...markdown.matchAll(/^```([^\n]*)\n([\s\S]*?)^```/gm)].map((match) => ({ + info: match[1]!, + lines: match[2]!.replace(/\n$/, '').split('\n'), + })); + +/** The lines of every block that starts with this info string. */ +const blocks = (info: string): string[][] => + fenced(guide) + .filter((block) => block.info === info) + .map((block) => [...block.lines]); /** The text under one second-level heading, up to the next one. */ function section(heading: string): string { @@ -30,60 +64,218 @@ function section(heading: string): string { return next === -1 ? body : body.slice(0, next); } -describe('the commands in the opening', () => { - const commands = blocks('sh repository').flatMap((block) => - block.split('\n').filter((line) => line.trim() !== ''), - ); +/** The text under one third-level heading, up to the next heading. */ +function subsection(heading: string): string { + const start = guide.indexOf(`\n### ${heading}\n`); + if (start === -1) throw new Error(`no subsection "${heading}"`); + const body = guide.slice(start + 1); + const next = body.search(/\n#{2,3} /); + return next === -1 ? body : body.slice(0, next); +} + +const slug = (heading: string): string => + heading + .toLowerCase() + .replace(/[`*_]/g, '') + .replace(/[^a-z0-9 -]/g, '') + .replace(/ /g, '-'); + +const significant = (lines: readonly string[]): string[] => + lines + .map((line) => line.trim()) + .filter((line) => line !== '' && !line.startsWith('//')); + +/** The quoted lines that are not lines of the source, each after the one before it. */ +function notInOrder( + quoted: readonly string[], + source: readonly string[], +): string[] { + const missing: string[] = []; + const lines = source.map((line) => line.trim()); + let from = 0; + for (const line of significant(quoted)) { + const at = lines.indexOf(line, from); + if (at < 0) missing.push(line); + else from = at + 1; + } + return missing; +} + +describe('the shape of the guide', () => { + it('is no longer marked as a draft, and has the sections of the design in order', () => { + expect(guide).not.toContain('**Draft.**'); + const headings = [...guide.matchAll(/^## (.+)$/gm)].map((m) => m[1]); + expect(headings).toEqual([ + 'The finished game', + 'Run it', + 'Your first click', + 'The layout and the operations', + 'What saga handles', + 'Make one change: a move list', + 'Connect it to Claude, ChatGPT and Alexa+', + 'What else this design allows', + 'Where to go next', + ]); + }); + + it('stays short enough to read in about ten minutes, and says that this is reading time', () => { + // About 250 words a minute is a slow reading speed for technical prose. Code is skimmed. + const prose = guide + .replace(/^```[\s\S]*?^```/gm, '') + .replace(/!\[[^\]]*\]\([^)]*\)/g, '') + .replace(/\]\([^)]*\)/g, ']'); + const words = prose.match(/[A-Za-z0-9`'’_.\-/+]+/g)?.length ?? 0; + expect(words).toBeLessThanOrEqual(2800); + expect(guide).toContain('about ten minutes'); + expect(guide).toContain('Ten minutes is reading time.'); + }); +}); + +describe('the commands in the guide', () => { + const commands = blocks('sh repository'); - it('are the three that build and start the example from a checkout, in order', () => { + it('are the four groups the guide shows, in order', () => { expect(commands).toEqual([ - 'npm ci', - 'npm run build:tic-tac-toe', - 'node apps/lit-host/dist/tic-tac-toe/server.mjs', + [ + 'npm ci', + 'npm run build:tic-tac-toe', + 'node apps/lit-host/dist/tic-tac-toe/server.mjs', + ], + ['node apps/lit-host/dist/tic-tac-toe-guided/server.mjs'], + [ + 'npm run build -w apps/lit-host', + 'npm run build:tic-tac-toe', + 'cd apps/lit-host', + 'npx playwright install chromium', + 'npx playwright test e2e/tic-tac-toe.spec.ts -g "adds a move list"', + ], + [ + 'npx tsx scripts/saga.ts init --a2ui-stack react-mcp --server hono ../my-saga', + ], ]); + // Nothing else is presented as something to type. + expect(blocks('sh')).toEqual([]); }); - it('name a script that exists and a server file that the build writes', () => { + it('name scripts, a workspace, a specification and a test that exist', () => { expect(rootManifest.scripts['build:tic-tac-toe']).toBe( 'tsx scripts/build-tic-tac-toe.ts', ); - const builder = fs.readFileSync( - path.join(repoRoot, 'scripts/build-tic-tac-toe.ts'), - 'utf8', - ); + const host = JSON.parse(read('apps/lit-host/package.json')) as { + name: string; + scripts: Record; + }; + expect(host.scripts['build']).toBeDefined(); + const spec = read('apps/lit-host/e2e/tic-tac-toe.spec.ts'); + expect(spec).toContain("test.describe('the guided change'"); + expect(spec).toContain('adds a move list'); + expect(spec).toContain('../dist/tic-tac-toe-guided/index.html'); + }); + + it('start servers that one build writes: the example and the guided change, each with its server.mjs', () => { + const builder = read('scripts/build-tic-tac-toe.ts'); expect(builder).toContain("path.join(outDir, 'server.mjs')"); expect(builder).toMatch(/'apps',\s*'lit-host',\s*'dist',\s*'tic-tac-toe'/); + expect(builder).toMatch( + /'apps',\s*'lit-host',\s*'dist',\s*'tic-tac-toe-guided'/, + ); + expect(builder).toContain('[ticTacToeDist, {}]'); + expect(builder).toContain('[ticTacToeGuidedDist, { guidedChange: true }]'); + }); + + it('build the fixture page before the example, as the root build does, because the host build clears the example', () => { + const build = rootManifest.scripts['build']!; + expect( + (JSON.parse(read('apps/lit-host/package.json')) as { name: string }).name, + ).toBe('@saga-a2ui/lit-host-app'); + expect(build.indexOf('@saga-a2ui/lit-host-app')).toBeGreaterThanOrEqual(0); + expect(build.indexOf('@saga-a2ui/lit-host-app')).toBeLessThan( + build.indexOf('build:tic-tac-toe'), + ); + const fixture = commands[2]!; + expect(fixture.indexOf('npm run build -w apps/lit-host')).toBeLessThan( + fixture.indexOf('npm run build:tic-tac-toe'), + ); + expect(guide).toContain("building it clears the example's build"); }); - it('print the endpoint line the server prints', () => { + it('print the endpoint line the server prints, on the port and path it uses', () => { const [printed] = blocks('text'); - expect(printed?.trim()).toBe( + expect(printed?.join('\n').trim()).toBe( 'Tic-tac-toe MCP endpoint: http://127.0.0.1:3000/mcp', ); - const main = fs.readFileSync( - path.join(repoRoot, 'examples/tic-tac-toe/server/main.ts'), - 'utf8', - ); + const main = example('server/main.ts'); expect(main).toContain('Tic-tac-toe MCP endpoint: ${listener.url}'); expect(main).toContain('process.env.PORT ?? 3000'); - const listener = fs.readFileSync( - path.join(repoRoot, 'examples/tic-tac-toe/server/listener.ts'), - 'utf8', - ); + const listener = example('server/listener.ts'); expect(listener).toContain("const GAME_PATH = '/mcp'"); expect(listener).toContain("const HOST = '127.0.0.1'"); }); it('say the Node version the repository requires', () => { - const nvmrc = fs - .readFileSync(path.join(repoRoot, '.nvmrc'), 'utf8') - .trim() - .replace(/^v/, ''); + const nvmrc = read('.nvmrc').trim().replace(/^v/, ''); expect(guide).toContain(`Node ${nvmrc} or newer`); }); + + it('create the starter the example came from, for a pair the CLI offers', async () => { + const init = commands[3]![0]!; + const [, stack, server, directory] = + /--a2ui-stack (\S+) --server (\S+) (\S+)$/.exec(init) as RegExpExecArray; + const pair = SUPPORTED_PAIRS.find( + (one) => one.stack === stack && one.server === server, + ); + expect(pair, `${stack} on ${server} is offered`).toBeDefined(); + let out = ''; + let err = ''; + const cwd = fs.mkdtempSync(path.join(work, 'init-')); + const code = await runCli( + ['init', '--a2ui-stack', stack!, '--server', server!, directory!], + { + cwd, + sdkVersion: () => '0.0.0-candidate.g0123456789ab', + stageDemo: () => { + throw new Error('init must not build the demonstration'); + }, + stdout: (text) => (out += text), + stderr: (text) => (err += text), + }, + ); + expect(code, err).toBe(0); + expect(out).toContain('Nothing was installed and no command was run.'); + const created = path.join(cwd, directory!); + for (const file of templateFiles(templateDirectory(pair!.template))) + expect(fs.existsSync(path.join(created, file)), file).toBe(true); + // The example was generated by this command. + expect(read(`${EXAMPLE}/README.md`)).toContain( + '`saga init --a2ui-stack react-mcp --server hono`', + ); + }); +}); + +describe('the route from a checkout, and the release it needs', () => { + it('says the source packages the game needs are newer than the published release', () => { + const published = /@saga-a2ui\/react-mcp-stack@(\S+?) /.exec( + read('docs/getting-started.md'), + )?.[1]; + const source = ( + JSON.parse(read('packages/react-mcp-stack/package.json')) as { + version: string; + } + ).version; + expect(published).toBeDefined(); + expect(source).not.toBe(published); + expect(guide).toContain(`(\`${source}\`)`); + expect(guide).toContain( + `\`${published}\` release on npm does not have them`, + ); + }); + + it('never tells the reader to install the example from the registry', () => { + expect(guide).not.toMatch(/npm (install|i) .*@saga-a2ui/); + }); }); -describe('what the opening quotes from the game', () => { +describe('what the guide quotes from the game', () => { it('uses the status lines the board shows', () => { expect(guide).toContain(`"${VIEW_TEXT.yourTurn}"`); expect(guide).toContain(`"${VIEW_TEXT.assistantTurn}"`); @@ -108,93 +300,284 @@ describe('what the opening quotes from the game', () => { }); it('describes the wait the controller has', () => { - const controller = fs.readFileSync( - path.join(repoRoot, 'examples/tic-tac-toe/src/controller.ts'), - 'utf8', - ); - expect(controller).toContain('everyMs: 1_500, reads: 40'); + expect(example('src/controller.ts')).toContain('everyMs: 1_500, reads: 40'); expect(guide).toContain('every second and a half, for up to a minute'); }); - it('names the operations the server declares', () => { - const contract = fs.readFileSync( - path.join(repoRoot, 'examples/tic-tac-toe/src/contract.ts'), - 'utf8', + it('quotes the lines of the move list the board shows', () => { + // The first click is the centre square, which is square 4: row 2, column 2. + expect(moveText({ player: 'X', square: 4 }, 0).text).toBe( + '1. You: row 2, column 2', + ); + expect(moveText({ player: 'O', square: 0 }, 1).text).toBe( + '2. Assistant: row 1, column 1', ); - for (const name of ['new_game', 'play_move', 'get_game']) - expect(contract, name).toContain(`'${name}'`); - for (const name of ['new_game', 'play_move', 'get_game']) - expect(guide, name).toContain(`\`${name}\``); + expect(guide).toContain('"1. You: row 2, column 2"'); + expect(guide).toContain('"2. Assistant: row 1, column 1"'); + }); + + it('says how long a game lives, that there is no sign-in, and where a host name is allowed', () => { + expect(DEFAULT_TTL_MS).toBe(30 * 60 * 1000); + expect(guide).toContain('expire thirty minutes after their last move'); + expect(example('src/contract.ts')).toContain('authorizes nothing else'); + expect(guide).toContain('The game has no sign-in'); + expect(example('server/listener.ts')).toContain('ALLOWED_HOSTNAMES'); + expect(guide).toContain('`ALLOWED_HOSTNAMES` in `server/listener.ts`'); }); }); -describe('the links and the screenshot', () => { - const targets = [...guide.matchAll(/\]\(([^)\s]+)\)/g)].map( - (match) => match[1]!, - ); +describe('the operations the guide describes', () => { + const operations = example('server/operations.ts'); + const constants: Record = { + [NEW_GAME_TOOL]: 'NEW_GAME_TOOL', + [SHOW_GAME_TOOL]: 'SHOW_GAME_TOOL', + [GET_GAME_TOOL]: 'GET_GAME_TOOL', + [PLAY_MOVE_TOOL]: 'PLAY_MOVE_TOOL', + }; + + /** Each `registerPublicOperation` call of the server, with its tool and whether it has a view. */ + const declared = operations + .split('registerPublicOperation(') + .slice(1) + .map((call) => ({ + constant: /name: (\w+),/.exec(call)![1]!, + view: /\bview: views\./.test(call.slice(0, call.indexOf('{ lifetime }'))), + })); + + const boardCalls = (constant: string): boolean => + [example('src/actions.ts'), example('src/controller.ts')].some((source) => + source.includes(constant), + ); - it('resolve to files and headings that exist', () => { - expect(targets.length).toBeGreaterThanOrEqual(4); + it('are four, and the table says which open the board and who calls them', () => { + expect(declared.map((one) => one.constant).sort()).toEqual( + Object.values(constants).sort(), + ); + const rows = section('The layout and the operations') + .split('\n') + .filter((line) => /^\| `[a-z_]+` \|/.test(line)) + .map((line) => line.split('|').map((cell) => cell.trim())); + expect(rows.map((row) => row[1])).toEqual( + ['new_game', 'show_game', 'get_game', 'play_move'].map( + (name) => `\`${name}\``, + ), + ); + for (const row of rows) { + const name = row[1]!.replace(/`/g, ''); + const constant = constants[name]!; + const source = declared.find((one) => one.constant === constant)!; + expect(row[3], `${name} opens the board`).toBe( + source.view ? 'Yes' : 'No', + ); + expect(row[2], `${name} is called by`).toBe( + boardCalls(constant) ? 'Assistant or board' : 'Assistant', + ); + } + }); + + it('show the data-only operation without a view, and name the two that add one', () => { + const quoted = fenced(guide).find((block) => + block.lines.some((line) => line.includes('name: GET_GAME_TOOL')), + )!; + expect(quoted.lines.join('\n')).not.toContain('view:'); + expect(operations).toContain('view: views.newGame'); + expect(operations).toContain('view: views.showGame'); + expect(guide).toContain('`view: views.newGame` and `view: views.showGame`'); + }); + + it('describe the arguments play_move takes, and a store that refuses a stale move whole', () => { + const contract = example('src/contract.ts'); + for (const field of ['gameId', 'square', 'player', 'expectedMoveNumber']) + expect(contract, field).toContain(`${field}:`); + expect(guide).toContain('`expectedMoveNumber`'); + expect(guide).toContain('a square from 0 to 8'); + const store = example('server/game-store.ts'); + expect(store).toContain('cannot both be applied'); + expect(store).toContain('A refused move changes nothing'); + }); +}); + +describe('the source the guide quotes', () => { + // Each excerpt is the source's own lines, in the source's order, with `// ...` for what is left + // out. A block that is not listed here is not allowed: a new excerpt has to be registered. + const sources: { readonly marker: string; readonly file: string }[] = [ + { marker: 'name: GET_GAME_TOOL', file: 'server/operations.ts' }, + { marker: 'const playSquare = Action.server', file: 'src/actions.ts' }, + { marker: 'bestEffort: true', file: 'src/actions.ts' }, + { + marker: "bindAction('activate', actions.playSquare", + file: 'src/view.tsx', + }, + ]; + const excerpts = fenced(guide).filter((block) => block.info === 'ts'); + + it('is the example’s own text, in order', () => { + expect(excerpts).toHaveLength(sources.length); + for (const source of sources) { + const excerpt = excerpts.find((block) => + block.lines.some((line) => line.includes(source.marker)), + ); + expect(excerpt, source.marker).toBeDefined(); + expect( + notInOrder(excerpt!.lines, example(source.file).split('\n')), + `${source.file}: ${source.marker}`, + ).toEqual([]); + } + }); + + it('would fail if an excerpt invented a line or reordered two', () => { + const source = example('server/operations.ts').split('\n'); + expect( + notInOrder( + [...excerpts[0]!.lines, ' handle: async () => forged,'], + source, + ), + ).toEqual(['handle: async () => forged,']); + // `play_move` is declared after `get_game`, so quoting them the other way round is caught. + expect( + notInOrder(['name: PLAY_MOVE_TOOL,', 'name: GET_GAME_TOOL,'], source), + ).toEqual(['name: GET_GAME_TOOL,']); + }); + + it('puts the context the move message carries inside the append the board makes', () => { + const actions = example('src/actions.ts'); + const append = actions.slice( + actions.indexOf('host.conversation.appendMessage(CONVERSATION.yourMove'), + ); + expect(append).toContain('context: {'); + expect(append.indexOf('context: {')).toBeLessThan( + append.indexOf('bestEffort: true'), + ); + }); +}); + +describe('the guided change', () => { + const patch = example('guided-change/move-list.patch'); + const shown = fenced(guide).find((block) => block.info === 'diff')!; + + it('is the patch the example ships, without its blob names, and nothing else', () => { + const expected = patch + .replace(/\n$/, '') + .split('\n') + .filter((line) => !line.startsWith('index ')); + expect(shown.lines).toEqual(expected); + }); + + it('touches only the layout and the catalog, as the guide says', () => { + const files = [...patch.matchAll(/^\+\+\+ b\/(.+)$/gm)].map((m) => m[1]); + expect(files).toEqual(['src/catalog.tsx', 'src/surface.ts']); + const change = section('Make one change: a move list'); + expect(change).toContain('only the layout and the catalog'); + expect(change).toContain( + 'The server, the controller and the actions do not change.', + ); + expect(change).toContain( + '`examples/tic-tac-toe/guided-change/move-list.patch`', + ); + }); + + it('adds a component the layout names, over data the board already supplies', () => { + expect(patch).toContain("component: 'MoveList'"); + expect(patch).toContain( + "children: { componentId: 'move', path: '/history' }", + ); + // The controller already feeds the layout's `history`, so nothing else has to change. + expect(example('src/controller.ts')).toContain( + 'history: (game?.history ?? []).map(moveText)', + ); + expect(example('src/surface.ts')).toContain('history: z.array('); + }); +}); + +describe('the links, the screenshot and the diagrams', () => { + const targets = [...guide.matchAll(/\]\(([^)\s]+)\)/g)].map((m) => m[1]!); + + it('resolve to files and headings that exist, including headings of this guide', () => { + expect(targets.length).toBeGreaterThanOrEqual(15); for (const target of targets) { if (/^https?:/.test(target)) continue; const [file = '', anchor] = target.split('#'); - const resolved = path.resolve( - path.dirname(path.join(repoRoot, GUIDE)), - file, - ); + const resolved = + file === '' + ? path.join(repoRoot, GUIDE) + : path.resolve(path.dirname(path.join(repoRoot, GUIDE)), file); expect(fs.existsSync(resolved), target).toBe(true); if (anchor !== undefined && resolved.endsWith('.md')) { const headings = fs .readFileSync(resolved, 'utf8') .split('\n') .filter((line) => /^#{1,6} /.test(line)) - .map((line) => - line - .replace(/^#+ /, '') - .toLowerCase() - .replace(/[^a-z0-9 -]/g, '') - .replace(/ /g, '-'), - ); + .map((line) => slug(line.replace(/^#+ /, ''))); expect(headings, target).toContain(anchor); } } }); - it('shows one screenshot, stored with Git LFS', () => { + it('link the next steps: the setup guide, the Lit track, the action guide, the host guide and the example', () => { + for (const target of [ + '(getting-started.md#create-a-new-project)', + '(build-notes-with-lit.md)', + '(actions.md)', + '(host-compatibility.md)', + '(../examples/tic-tac-toe/README.md)', + '(react-consumers.md)', + '(attach-to-existing-backend.md)', + ]) + expect(guide, target).toContain(target); + }); + + it('show the screenshot, stored with Git LFS, and the three diagrams, each with its sources', () => { const images = [...guide.matchAll(/!\[[^\]]*\]\(([^)\s]+)\)/g)].map( - (match) => match[1]!, - ); - expect(images).toEqual(['images/tic-tac-toe-board.png']); - const file = path.join(repoRoot, 'docs', images[0]!); - const head = fs.readFileSync(file).subarray(0, 64); - // A checkout either holds the pointer (the LFS object not fetched) or the PNG itself. - const isPointer = head - .toString('utf8') - .startsWith('version https://git-lfs'); - const isPng = head.subarray(1, 4).toString('latin1') === 'PNG'; - expect(isPointer || isPng).toBe(true); - const attributes = fs.readFileSync( - path.join(repoRoot, '.gitattributes'), - 'utf8', - ); - expect(attributes).toContain('*.png filter=lfs'); - }); - - it('gives the screenshot a description that matches what it shows', () => { - const alt = /!\[([^\]]*)\]/.exec(guide)?.[1] ?? ''; + (match) => match[1], + ); + expect(images).toEqual([ + 'images/tic-tac-toe-board.png', + '../diagrams/tic-tac-toe-turn-loop.svg', + '../diagrams/tic-tac-toe-arrangements.svg', + '../diagrams/tic-tac-toe-a2a-delivery.svg', + ]); + expect(read('.gitattributes')).toContain('*.png filter=lfs'); + for (const name of ['turn-loop', 'arrangements', 'a2a-delivery']) { + const base = `diagrams/tic-tac-toe-${name}`; + for (const extension of ['mmd', 'svg', 'png']) + expect(fs.existsSync(path.join(repoRoot, `${base}.${extension}`))).toBe( + true, + ); + expect(guide).toContain(`[Editable Mermaid](../${base}.mmd)`); + expect(guide).toContain(`[PNG](../${base}.png)`); + } + for (const file of [ + 'docs/images/tic-tac-toe-board.png', + 'diagrams/tic-tac-toe-turn-loop.png', + 'diagrams/tic-tac-toe-arrangements.png', + 'diagrams/tic-tac-toe-a2a-delivery.png', + ]) { + const head = fs.readFileSync(path.join(repoRoot, file)).subarray(0, 64); + // A checkout either holds the pointer (the LFS object not fetched) or the PNG itself. + const isPointer = head + .toString('utf8') + .startsWith('version https://git-lfs'); + const isPng = head.subarray(1, 4).toString('latin1') === 'PNG'; + expect(isPointer || isPng, file).toBe(true); + } + }); + + it('give every image a description, and the screenshot one that matches what it shows', () => { + const alts = [...guide.matchAll(/!\[([^\]]*)\]/g)].map((m) => m[1]!); + expect(alts).toHaveLength(4); + for (const alt of alts) expect(alt.length, alt).toBeGreaterThan(40); for (const part of [ 'Your move. You are X.', 'New game', 'O at the top left', + 'a few moves into a game', ]) - expect(alt, part).toContain(part); + expect(alts[0], part).toContain(part); }); it('presents the screenshot as a game in progress and starts the first click from an empty board', () => { // The screenshot holds four marks, so its centre is taken: it cannot be the board a new game // shows, and the walkthrough must not ask the reader to click the centre of it. - const alt = /!\[([^\]]*)\]/.exec(guide)?.[1] ?? ''; - expect(alt).toContain('a few moves into a game'); const finished = section('The finished game'); expect(finished).toContain('a new board with nine empty squares'); expect(finished).toContain('a few moves into a game'); @@ -203,13 +586,187 @@ describe('the links and the screenshot', () => { expect(firstClick).toContain('Click the centre square of the empty board'); expect(firstClick).not.toContain('the board above'); }); + + it('draw the new diagrams from sources that name what the guide names, and render them', () => { + const arrangements = read('diagrams/tic-tac-toe-arrangements.mmd'); + const arrangementsSvg = read('diagrams/tic-tac-toe-arrangements.svg'); + for (const label of [ + 'Workers KV', + 'Durable Object', + 'Native A2UI host', + 'Gateway server', + 'App API server', + 'Lit catalog', + 'React catalog', + 'message/send', + ]) { + expect(arrangements, label).toContain(label); + expect(arrangementsSvg, label).toContain(label); + } + for (const kind of ['runnable', 'compose', 'sketch']) + expect(arrangements).toContain(`classDef ${kind}`); + const a2a = read('diagrams/tic-tac-toe-a2a-delivery.mmd'); + const a2aSvg = read('diagrams/tic-tac-toe-a2a-delivery.svg'); + for (const label of [ + 'appendMessage', + 'message/send', + 'data {game}', + 'Architectural sketch', + 'play_move(O, expectedMoveNumber 1)', + ]) + expect(a2a, label).toContain(label); + for (const label of [ + 'appendMessage', + 'message/send', + 'Architectural sketch', + ]) + expect(a2aSvg, label).toContain(label); + }); }); -describe('what the opening says about hosts', () => { - it('claims no host run and points at the host compatibility guide', () => { +describe('what the guide says about hosts', () => { + const hosts = section('Connect it to Claude, ChatGPT and Alexa+'); + const compatibility = read('docs/host-compatibility.md'); + + it('claims no host run, in the opening and in each host section, and links the host compatibility guide', () => { expect(guide).toContain( 'None of the three has been run against this game yet', ); - expect(guide).toContain('(host-compatibility.md)'); + expect(hosts).toContain('None of the three has been run against this game'); + expect(hosts).toContain('(host-compatibility.md)'); + for (const host of ['Claude', 'ChatGPT', 'Alexa+']) { + const text = subsection(host); + expect(text, host).toMatch(/unverified/i); + expect(text, host).toContain('(host-compatibility.md'); + } + // No sentence reports that a host was run, or that the game was seen working there. + expect(guide).not.toMatch( + /\b(we|I) (ran|tested|played|tried)\b|verified (in|on|with) (Claude|ChatGPT|Alexa)|works (in|on) (Claude|ChatGPT|Alexa\+)/i, + ); + }); + + it('describes a voice or conversation move as a different interaction from clicking the board', () => { + const alexa = subsection('Alexa+'); + expect(alexa).toContain( + 'A voice or conversation move is a different interaction from clicking the board.', + ); + for (const part of [ + 'no click', + 'no message appended by the board', + 'no wait', + ]) + expect(alexa, part).toContain(part); + // The tool the assistant uses for the person's move says it may place X when told to. + const operations = example('server/operations.ts'); + expect(operations).toContain( + 'Place X only when the person tells you their move in the conversation', + ); + expect(operations).toContain('call show_game to display the board'); + }); + + it('rests each host statement on what the host compatibility guide records', () => { + expect(compatibility).toContain('about 30 seconds'); + expect(compatibility).toContain('Try again'); + expect(compatibility).toContain('A ChatGPT reload removed a pending view'); + expect(compatibility).toContain('Existing-frame in-card calls failed'); + expect(compatibility).toContain('## ChatGPT and Claude'); + expect(compatibility).toContain('## Alexa+'); + // It records no host taking a message that a card appends, so the guide cannot say one did. + expect(compatibility).not.toMatch(/appendMessage|ui\/message/); + expect(hosts).toContain('about 30 seconds'); + expect(hosts).toContain('Try again'); + expect(hosts).toContain('one recorded reload removed a pending view'); + expect(hosts).toContain('did not complete usably in the recorded probes'); + expect(hosts).toContain("No recorded run covers this game's hand-over"); + }); + + it('says what a host needs before it can reach the server', () => { + expect(hosts).toContain('the loopback address is not enough'); + expect(hosts).toContain('public HTTPS address'); + }); +}); + +describe('what else the design allows', () => { + const closing = section('What else this design allows'); + const labels = [ + 'Runnable', + 'Straightforward composition', + 'Architectural sketch', + ]; + + it('defines the three labels, and every arrangement carries exactly one of them', () => { + const legend = closing + .split('\n') + .filter((line) => + /^\| (Runnable|Straightforward composition|Architectural sketch) \|/.test( + line, + ), + ) + .map((line) => line.split('|')[1]!.trim()); + expect(legend).toEqual(labels); + const items = [...closing.matchAll(/^- \*\*(.+?)\.\*\* \*(.+?)\.\*/gm)].map( + (match) => [match[1], match[2]], + ); + expect(items).toEqual([ + ['The game as built', 'Runnable'], + [ + 'A gateway and an app API on separate servers', + 'Straightforward composition', + ], + ['Renderer independence', 'Straightforward composition'], + ['A native A2UI host without MCP', 'Architectural sketch'], + ['A2A delivery', 'Architectural sketch'], + ['`GameStore` on Workers KV or a Durable Object', 'Architectural sketch'], + ]); + }); + + it('is true to the example: the seams it names are there, and only the view is tied to React', () => { + const store = example('server/game-store.ts'); + expect(store).toContain('export interface GameStore'); + expect(store).toContain('a conditional write on the move number'); + expect(store).toContain('a key-value store, a Durable Object'); + for (const file of [ + 'src/contract.ts', + 'server/rules.ts', + 'server/game-store.ts', + 'server/operations.ts', + ]) + expect(example(file), file).not.toMatch( + /from ['"]react(-dom)?(\/[^'"]*)?['"]/, + ); + expect(example('src/surface.ts')).not.toMatch(/from ['"]react/); + const actions = example('src/actions.ts'); + expect(actions).toContain('host.server'); + expect(actions).toContain('host.conversation.appendMessage'); + // The Lit stack hands a view the same two, and exports the same layout and action builders. + const lit = read('packages/lit-mcp-stack/src/start-a2ui-view.ts'); + expect(lit).toContain('readonly conversation: Conversation;'); + const litIndex = read('packages/lit-mcp-stack/src/index.ts'); + expect(litIndex).toContain('export { defineSurface }'); + expect(litIndex).toContain('export { Action'); + }); + + it('describes the A2A mapping as the conversation design does, and as work that does not exist yet', () => { + const design = read('blueprints/features/view_conversation.blueprint.md'); + expect(design).toContain( + '`context` would become a data part on that message', + ); + expect(design).toContain('One `message/send`'); + expect(design).toContain('issues/198'); + const a2a = closing + .split('\n') + .find((line) => line.startsWith('- **A2A delivery.**'))!; + expect(a2a).toContain('`appendMessage` would become an A2A `message/send`'); + expect(a2a).toContain('its `context` would become a data part'); + expect(a2a).toContain('nothing sends it today'); + expect(a2a).toContain('issues/198'); + }); + + it('ships neither a Workers store nor a gateway, so the guide does not call them runnable', () => { + const shipped = fs + .readdirSync(path.join(repoRoot, EXAMPLE, 'server')) + .join(' '); + expect(shipped).not.toMatch(/kv|durable|worker|gateway/i); + expect(closing).toContain('The repository ships neither store'); }); }); diff --git a/scripts/tic-tac-toe.test.ts b/scripts/tic-tac-toe.test.ts index f1c6ef18..6a3e4468 100644 --- a/scripts/tic-tac-toe.test.ts +++ b/scripts/tic-tac-toe.test.ts @@ -20,7 +20,7 @@ import { templateDirectory } from './starter-template.js'; // examples/tic-tac-toe: generated with the React/Hono `saga init`, keeping the template's // structure, and built from packed SDK packages. The rules and the store are checked by // tic-tac-toe-rules.test.ts, the page by apps/lit-host/e2e/tic-tac-toe.spec.ts, and the -// opening of the guide by tic-tac-toe-document.test.ts. +// guide by tic-tac-toe-document.test.ts. const template = templateDirectory('react-hono'); const work = createWorkDirectory('saga-tic-tac-toe-test-'); From 15c02bb53830b5e348246064f1c4a4a4bb1b0f93 Mon Sep 17 00:00:00 2001 From: yia-mw-agent Date: Thu, 29 Oct 2026 13:47:54 +0000 Subject: [PATCH 2/2] docs: describe cancellation as ending a wait and signalling the handler --- docs/build-tic-tac-toe-with-react.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/build-tic-tac-toe-with-react.md b/docs/build-tic-tac-toe-with-react.md index bf9005b2..fadb80df 100644 --- a/docs/build-tic-tac-toe-with-react.md +++ b/docs/build-tic-tac-toe-with-react.md @@ -161,7 +161,7 @@ If a call fails or times out, the move may or may not have been applied. The boa You write the game: its rules, where it is stored, the layout, the components, the actions and the sentences. saga handles the plumbing: -- **Operations.** It checks every input and output against the contracts you declare, enforces time limits, ends cancelled work, and turns a failure into a fixed safe message. +- **Operations.** It checks every input and output against the contracts you declare, bounds how long a call waits, gives your handler a cancellation signal when the wait ends, and turns a failure into a fixed safe message. A handler that ignores the signal keeps running, and ending a wait does not undo what it already did, so make your own work stop when the signal fires. - **The board.** It serves the board as an operation's card, connects it to the host, keeps a result that arrives early, and ends the view once when the host or page goes away. - **Actions.** It attaches one definition to each control with state of its own, so nine squares share one action. - **The conversation.** It adds the message and its context, and says whether the host took them.