From 0a6e1a5c3df6ff93c8335b7a72f5a4468959d778 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Wed, 2 Sep 2026 10:23:45 +0000 Subject: [PATCH] docs: rewrite README as a landing page; move reference content into docs/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The README was a landing page welded to the pre-Diátaxis reference manual (863 lines, first code at line 187, a Vercel comparison table before Install). Rewrite it as a ~200-line landing page: value proposition, a complete build-checked hello-world Slack bot, install, feature highlights linking into docs/, adapter maturity, the Diátaxis index, the Vercel relationship in three sentences, short non-goals, and status. Relocate every removed reference section into its Diátaxis home: runtime semantics by concept plus per-adapter status and the testing contract into docs/reference.md; design goals, the Vercel comparison table, the long-form non-goals, and the intentional gaps into docs/explanation.md. Dedupe against existing how-tos rather than paste, and fix two stale how-to claims found on the way (lost lock lease cancels the handler with ErrPreempted; block_actions admission rejection is a 503, only slash commands get the busy text). documentation_test.go: retarget every README phrase assertion to the phrase's new home (none removed) and add TestREADMEMarkedSnippetsBuild, which compiles the README's marked Go block against the module. --- README.md | 953 +++++-------------------------- docs/README.md | 10 +- docs/explanation.md | 130 ++++- docs/how-to/deferred-dispatch.md | 21 +- docs/reference.md | 596 ++++++++++++++++++- documentation_test.go | 109 +++- 6 files changed, 970 insertions(+), 849 deletions(-) diff --git a/README.md b/README.md index b7109ec..e9068c3 100644 --- a/README.md +++ b/README.md @@ -1,863 +1,204 @@ # Chat SDK Go [![CI](https://github.com/coder/chat/actions/workflows/ci.yaml/badge.svg?branch=main)](https://github.com/coder/chat/actions/workflows/ci.yaml) - -Chat SDK Go is a Go-native semantic subset of Vercel Chat SDK's -conversation runtime: adapters, normalized events, threads, subscriptions, -state-backed dedupe, and thread-scoped replies. - -This is not a TypeScript API port and not a promise of full Vercel Chat SDK -feature parity. The goal is semantic compatibility where the model maps cleanly -to Go, with deliberate Go-shaped differences where that makes the runtime -simpler, safer, or easier to operate. - -Status: the core runtime, the Slack adapter, the Linear adapter (agent -sessions and generic issue comments), four state backends (memory, Redis, -Postgres, NATS JetStream), runnable examples, and public contract tests are in -place. The public Go API surface is still early and may change. - -## Adapter Maturity - -Adapters are tiered honestly: - -- **`supported`** — production-grade: hardening test suites, rate-limit - handling, multi-tenant installs, and documentation. A reasonable default - choice for production. -- **`experimental`** — implemented and tested, but no promises: the platform - surface, the adapter API, or both may still change. - -| Adapter | Tier | Notes | -| --- | --- | --- | -| Slack (`adapters/slack`) | `supported` | Hardening tests for rate-limit retry ([ADR 0005](docs/adr/0005-rate-limit-handling.md)), multi-tenant installs ([ADR 0006](docs/adr/0006-multi-tenant-install.md)), history read-through ([ADR 0009](docs/adr/0009-message-history.md)), and interactivity. No live end-to-end Slack test runs in CI. | -| Linear (`adapters/linear`) | `experimental` | Fully implemented and hardened (agent sessions, generic comments, rate-limit retry, multi-tenant, history read-through), but the upstream Linear agent API is itself in developer preview and [capability gaps remain](docs/linear-agent-capabilities.md) (some operations are GraphQL-escape-hatch only). | -| Microsoft Teams | spike | [ADR 0007](docs/adr/0007-teams-adapter.md) is a proposal gated on a live-tenant spike (draft [PR #4](https://github.com/coder/chat/pull/4), tracked in [#6](https://github.com/coder/chat/issues/6)). Not usable yet. | - -## Documentation - -Documentation follows [Diátaxis](https://diataxis.fr/). The -[docs index](docs/README.md) maps it all; the short version: - -- **Tutorial**: [your first Slack bot](docs/tutorials/slack-bot.md) — zero to - a running bot in under 30 minutes. -- **How-to guides**: [state backends](docs/how-to/choose-a-state-backend.md), - [deferred dispatch](docs/how-to/deferred-dispatch.md), - [slash commands](docs/how-to/slash-commands.md), - [interactive components](docs/how-to/interactive-components.md), - [multi-tenant installs](docs/how-to/multi-tenant-install.md), and - [Linear agent sessions](docs/how-to/linear-agent-sessions.md). -- **Reference**: [package and API reference](docs/reference.md) (pkg.go.dev - pointers and per-adapter capability status). -- **Explanation**: [architecture and design decisions](docs/explanation.md) - — an index over [`CONTEXT.md`](CONTEXT.md) and the [ADRs](docs/adr/). - -## Vercel Chat SDK Alignment - -This project follows Vercel Chat SDK's conversation semantics where they fit -Go, built outward from a production-shaped Slack slice. The table below is the -quick status map for readers familiar with Vercel Chat SDK: - -| Vercel Chat SDK concept | Chat SDK Go status | -| --- | --- | -| `Chat` runtime | Implemented as `chat.Chat` | -| Platform adapters | Slack (supported) and Linear (experimental) implemented; Teams is a spike | -| Normalized events and thread-scoped replies | Implemented | -| `onNewMention` | Implemented as `OnNewMention` | -| `onSubscribedMessage` | Implemented as `OnSubscribedMessage` | -| Thread subscriptions | Implemented with explicit `Thread.Subscribe` / `Thread.Unsubscribe` | -| Runtime state adapters | Memory, Redis, Postgres, and NATS JetStream implemented | -| Direct messages | Routed as implicit new mentions, then subscribed messages | -| Ephemeral messages | Slack native ephemeral plus explicit DM fallback | -| Thread handle reconstruction | Implemented with `Chat.Thread` | -| AI streaming responses | Deferred from core, not foreclosed (ADR 0011); long generation uses ack-then-work | -| Slash commands | Implemented as `OnCommand` Command Events (Slack) | -| Interactive components (buttons, menus) | Implemented as `OnInteraction` block_actions (Slack) | -| Native rich content (Block Kit) | Implemented as `NativeContentPoster` Optional Capability (Slack) | -| Modal open (`views.open`) | Implemented as a Slack adapter Optional Capability | -| Modal `view_submission` synchronous response | Deferred (incompatible with ack-then-work) | -| Cards, JSX-style cards, native payload builders | Not yet implemented | -| Pattern handlers | Not yet implemented | -| Observability metrics/tracing | Optional `Observer` seam, no-op default, no OTel dependency in core | -| Message history persistence | App-owned (Thread Application State); thin live read-through via `HistoryReader` Optional Capability (Slack, Linear) | -| AI-message conversion helpers | Not yet implemented | -| Multiple production adapters | Slack is the only `supported` adapter; Linear is `experimental` | -| Middleware | Not yet implemented | - -## Design Goals - -- Go-native API built around `context.Context`, `net/http`, small interfaces, - and explicit errors. -- Slack-first vertical slice before claiming multi-platform portability. -- Required runtime state for subscriptions, dedupe, and locks. -- Memory state for tests and local development. -- Redis, Postgres, or NATS JetStream state for horizontally scaled production - deployments. -- Thread-oriented application code: handle a message, subscribe the thread, - reply to the thread. -- Platform escape hatches without making raw platform structs the normal API. -- Vercel Chat SDK behavior as the default precedent unless it is non-idiomatic - in Go or outside the documented scope. - -## Install - -The core module is: - -```sh -go get github.com/coder/chat -``` - -Redis, Postgres, and NATS state are optional and live in separate modules so -applications that only use core, Slack, or memory state do not pull production -state dependencies: - -```sh -go get github.com/coder/chat/state/redis -go get github.com/coder/chat/state/postgres -go get github.com/coder/chat/state/nats -``` - -Package layout: - -```text -github.com/coder/chat -github.com/coder/chat/adapters/slack -github.com/coder/chat/adapters/linear -github.com/coder/chat/state/memory -github.com/coder/chat/state/nats -github.com/coder/chat/state/postgres -github.com/coder/chat/state/redis -``` - -This repository uses `go.work` for local development across the root module, -state modules, and example modules. - -## Examples And Local Services - -Which example should you run? - -- Start with `examples/slack-hello-world` if you are new to the SDK or want a - memory-backed bot with no local infrastructure. The - [tutorial](docs/tutorials/slack-bot.md) walks through it end to end. -- Use `examples/linear-agent-hello-world` if you want to dogfood Linear - app-actor agent sessions with memory state. -- Use `examples/slack-redis-state` to try durable runtime coordination with - Redis. -- Use `examples/slack-postgres-state` if Postgres is already your coordination - store. -- Use `examples/slack-nats-state` if you already run NATS with JetStream. - -The memory-backed Slack example runs without local infrastructure: - -```sh -go run ./examples/slack-hello-world -``` - -The memory-backed Linear app-actor example also runs without local -infrastructure, but it requires a Linear OAuth app installed as an app actor and -a public HTTPS webhook URL: - -```sh -go run ./examples/linear-agent-hello-world -``` - -The state-backed Slack examples live in separate example modules so the core -module does not pull Redis, Postgres, or NATS dependencies just to build the -basic example: - -- `examples/slack-redis-state` -- `examples/slack-postgres-state` -- `examples/slack-nats-state` - -Each state-backed example has its own `compose.yaml`, `pitchfork.toml`, and -README with the backend URL, service startup commands, and Slack setup steps. -For example: - -```sh -cd examples/slack-redis-state -docker compose up -d redis -go run . -``` - -You can also let Pitchfork supervise an example's local service from that -example directory: - -```sh -pitchfork start redis -``` - -## Tiny Slack Example - -The core handler for a minimal bot can be tiny: - -```go -bot.OnNewMention(func(ctx context.Context, ev *chat.MessageEvent) error { - _, err := ev.Thread.Post(ctx, chat.Text("hello world")) - return err -}) -``` - -Replying does not subscribe the thread. Call `ev.Thread.Subscribe(ctx)` when -you want later messages in the same thread to route to `OnSubscribedMessage`. - -## Production-Shaped Example - +[![Go Reference](https://pkg.go.dev/badge/github.com/coder/chat.svg)](https://pkg.go.dev/github.com/coder/chat) +[![Latest release](https://img.shields.io/github/v/release/coder/chat)](https://github.com/coder/chat/releases/latest) + +Chat SDK Go is a Go runtime for building chat bots and agents on Slack and +Linear. You write handlers against a normalized event model — a mention +arrives, you reply in its thread, you subscribe to keep the conversation +going — and the runtime takes care of the platform plumbing: webhook +verification, event normalization, thread-scoped replies, event dedupe and +per-thread locking in shared state (Redis, Postgres, or NATS JetStream in +production; memory for development) so horizontally scaled replicas dedupe +redeliveries and serialize work per thread, deferred ack-then-work dispatch +with admission bounds for slow handlers such as LLM calls, multi-tenant +installs, and platform rate-limit retries. The API is small, explicit Go — +`context.Context`, `net/http`, small interfaces, returned errors — rather +than a framework. + +## Hello, Slack + +A complete bot that replies to every mention: + + ```go package main import ( "context" - "log/slog" + "log" "net/http" "os" - "time" - - "github.com/redis/go-redis/v9" "github.com/coder/chat" "github.com/coder/chat/adapters/slack" - chatredis "github.com/coder/chat/state/redis" + "github.com/coder/chat/state/memory" ) func main() { ctx := context.Background() - redisState, err := chatredis.New(ctx, chatredis.Options{ - Client: redis.NewClient(&redis.Options{ - Addr: os.Getenv("REDIS_ADDR"), - }), - }) - if err != nil { - panic(err) - } - slackAdapter, err := slack.New(ctx, slack.Options{ SigningSecret: os.Getenv("SLACK_SIGNING_SECRET"), BotToken: os.Getenv("SLACK_BOT_TOKEN"), }) if err != nil { - panic(err) + log.Fatal(err) } bot, err := chat.New(ctx, - chat.WithState(redisState), + chat.WithState(memory.New()), // swap for Redis, Postgres, or NATS in production chat.WithAdapter(slackAdapter), - chat.WithLogger(slog.Default()), - chat.WithRuntimeOptions(chat.RuntimeOptions{ - DedupeTTL: 24 * time.Hour, - ThreadLockTTL: 2 * time.Minute, - Concurrency: chat.ConcurrencyDrop, - }), ) if err != nil { - panic(err) + log.Fatal(err) } - defer func() { - if err := bot.Shutdown(context.Background()); err != nil { - slog.Error("chat shutdown failed", "error", err) - } - }() bot.OnNewMention(func(ctx context.Context, ev *chat.MessageEvent) error { - if !userIsLinked(ev.Message.Author) { - _, err := ev.Thread.PostEphemeral(ctx, ev.Message.Author, chat.Text( - "Please link your account before I continue.", - ), chat.EphemeralOptions{ - FallbackToDM: true, - }) - return err - } - - if err := ev.Thread.Subscribe(ctx); err != nil { - return err - } - - _, err := ev.Thread.Post(ctx, chat.Markdown( - "I'm listening to this thread now.", - )) - return err - }) - - bot.OnSubscribedMessage(func(ctx context.Context, ev *chat.MessageEvent) error { - _, err := ev.Thread.Post(ctx, chat.Text("You said: "+ev.Message.Text)) + _, err := ev.Thread.Post(ctx, chat.Markdown("**hello** _world_")) return err }) - slackWebhook, err := bot.Webhook("slack") + webhook, err := bot.Webhook("slack") if err != nil { - panic(err) + log.Fatal(err) } - - http.Handle("/webhooks/slack", slackWebhook) - if err := http.ListenAndServe(":8080", nil); err != nil { - panic(err) - } -} - -func userIsLinked(chat.Actor) bool { - return false -} -``` - -## Core Model - -`Chat` is the runtime. It owns adapter registration, runtime state, handler -registration, webhook mounting, dispatch, dedupe, locking, and shutdown. - -`Platform Adapter` is a platform boundary. It verifies inbound webhooks, -normalizes platform payloads, renders outbound messages, and exposes -platform-specific APIs through typed adapter access. It does not own application -routing. - -`Event` is the normalized inbound envelope. A `Message` is one payload type -inside an event, not the name for every inbound platform occurrence. - -`MessageEvent` is the handler input for message routing hooks. It carries the -normalized event, thread, and message together. - -`Thread` is the stable conversation address used for routing, subscription, -and replies. In Slack, a root channel message becomes a thread rooted at that -message timestamp, not the entire channel. - -`ThreadID` is opaque and adapter-produced. It must include adapter identity and -enough platform tenant/routing context to avoid collisions across workspaces, -channels, and platforms. Application code may store and pass it around, but -must not build it manually. - -`Thread Handle` reconstruction is supported for out-of-webhook work: - -```go -thread, err := bot.Thread(ctx, threadID) -if err != nil { - return err -} - -_, err = thread.Post(ctx, chat.Text("Reminder")) -``` - -The runtime decodes the adapter prefix, asks the adapter to validate the -thread ID, and returns an error for unknown adapters or invalid IDs. - -## Runtime Construction - -Construction is fail-fast: - -```go -bot, err := chat.New(ctx, - chat.WithState(state), - chat.WithAdapter(slackAdapter), -) -``` - -`chat.New` validates state, adapter registration, runtime options, and adapter -initialization before webhooks are served. This is an intentional difference -from Vercel Chat SDK, which initializes lazily on first use. - -`Shutdown(ctx)` is idempotent. It attempts all adapter cleanup hooks before -state cleanup and returns joined errors if cleanup fails. - -## Webhooks - -The runtime exposes `net/http` handlers and does not own the HTTP server: - -```go -handler, err := bot.Webhook("slack") -if err != nil { - return err + http.Handle("/webhooks/slack", webhook) + log.Fatal(http.ListenAndServe(":8080", nil)) } - -http.Handle("/webhooks/slack", handler) -``` - -Webhook lookup is fallible. A misspelled adapter name is a startup/configuration -error, not a production 404. - -Adapters own platform handshakes. For Slack, URL verification is handled inside -the Slack webhook handler and never reaches application handlers. - -## Routing - -The runtime has two message routing hooks: - -```go -bot.OnNewMention(func(context.Context, *chat.MessageEvent) error) -bot.OnSubscribedMessage(func(context.Context, *chat.MessageEvent) error) ``` -Routing order: - -1. Ignore self-authored bot messages. -2. Route messages in subscribed threads to `OnSubscribedMessage`. -3. Route mentions in unsubscribed threads to `OnNewMention`. -4. A valid but unsupported or irrelevant platform event is acknowledged and - ignored. - -Direct messages are treated as implicit mentions. An unsubscribed direct message -routes to `OnNewMention`; once subscribed, later direct messages route to -`OnSubscribedMessage`. - -Handlers are single-slot per hook. Calling `OnNewMention` or -`OnSubscribedMessage` again atomically replaces the previous handler. Missing -handlers are no-ops. This intentionally differs from Vercel Chat SDK, which -allows multiple handlers per hook. - -Subscriptions are explicit: - -```go -if err := ev.Thread.Subscribe(ctx); err != nil { - return err -} -``` - -Replying successfully to a new mention does not subscribe the thread. A -subscription lasts until explicit unsubscribe. - -### Command And Interaction Events - -A slash command and a button click are **Events**, not **Messages**. They ride the -same dispatch spine (dedupe by Event Identity, Thread Lock, self-filtering, -lock-conflict acknowledge-and-drop) but route to their own single-slot hooks: - -- `OnCommand(func(ctx, *chat.CommandEvent) error)` for Command Events (Slack slash - commands). Command-ness takes precedence over subscription state: a command in a - subscribed thread still routes to `OnCommand`, never to `OnSubscribedMessage`. A - command does not auto-subscribe its thread. -- `OnInteraction(func(ctx, *chat.InteractionEvent) error)` for Interaction Events. - This slice handles Slack `block_actions` (button clicks, menu selections). - -Both hooks are single-slot and no-op-when-unset, like the message hooks; an unset -handler is still acknowledged. The platform ack is adapter-owned: the Slack adapter -returns an empty 2xx and preserves `response_url` / `trigger_id` on the `Raw` -Platform Escape Hatch. Under the default synchronous dispatch the handler runs -before that ack, so long command/interaction work should use the same -`DispatchDeferred` ack-then-work primitive as messages (ADR 0002) to stay inside -Slack's 3-second budget; bots expecting commands or clicks mid-conversation -should select the `queue` Concurrency Strategy. - -Native command/interaction responses and Block Kit content are NOT added to -Postable Message, which stays Plain Text + Portable Markdown. They are reached -deliberately through typed Adapter Access: - -- `chat.NativeContentPoster.PostNative` posts opaque Block Kit blocks. A - `NativeContent` whose adapter does not match the target is an error, never a - silent portable downgrade. -- The Slack adapter's `OpenModalFromRaw` (and `OpenModal` for callers holding a - `trigger_id`) opens a modal via `views.open` using the `trigger_id` preserved - on the `Raw` escape hatch. The synchronous modal `view_submission` response is - deferred because it is incompatible with ack-then-work. -- The Slack adapter's `RespondURL` posts to a preserved `response_url`. - -### Observability - -Runtime Observation defaults to structured `slog`, unchanged. An optional -`WithObserver(Observer)` seam adds counter-style point events (dedupe hit, lock -conflict, ignored-event-by-reason, handler error, lock-release failure, adapter -call, rate limit) and a per-dispatch span with a terminal outcome -(`handled`, `ignored`, `dropped-lock-conflict`, `duplicate`, `error`). The default -is a no-op Observer, so an unconfigured runtime behaves exactly as before. The core -imports no OpenTelemetry, Prometheus, or statsd; attribute keys are a closed, -low-cardinality set (`adapter`, `route`, `reason`, `outcome`, `tenant`) and never -carry Thread ID, message text, or raw actor IDs. Observer calls are panic-safe: a -broken Observer can never fail an Accepted Event or alter acknowledgement. Under -deferred dispatch the span follows the Detached Work Context so ack-then-work -latency is measured to handler completion. - -## Dispatch And Acknowledgement - -The default dispatch mode is synchronous (`DispatchSync`): handlers run on the -inbound webhook request context before the platform acknowledgement. For -long-running work, opt in to `DispatchDeferred` (ack-then-work, ADR 0002): the -dedupe/lock prelude runs before the ack, then the handler runs on a detached -work context with automatic lock lease renewal. See the -[deferred dispatch guide](docs/how-to/deferred-dispatch.md). - -Once a webhook is verified and normalized into an accepted event, handler errors -are recorded but acknowledged to the platform by default. This avoids platform -retry storms after partial side effects such as posting a message. - -Invalid signatures and malformed requests are rejected. Valid but unsupported -platform events are acknowledged and ignored. - -## Runtime State - -State is required. The runtime must not silently create memory state for -production-facing construction. - -Runtime state is coordination state: - -- subscribed thread membership -- event dedupe -- thread locks -- runtime cache needed by adapters - -Runtime state is not product state. Store application workflow data in your own -database keyed by `ThreadID`. - -State implementations: - -- `state/memory`: tests and local development, included in the root module -- `state/postgres`: production and horizontally scaled deployments, kept in the - separate `github.com/coder/chat/state/postgres` module -- `state/redis`: production and horizontally scaled deployments, kept in the - separate `github.com/coder/chat/state/redis` module -- `state/nats`: production deployments that already run NATS with JetStream, - kept in the separate `github.com/coder/chat/state/nats` module - -The [state backend guide](docs/how-to/choose-a-state-backend.md) compares them. - -## Dedupe, Locks, And Concurrency - -Event dedupe uses `Event Identity`, not delivery retry metadata. Slack retry -headers are logged as retry metadata but are not part of the dedupe key. - -Default runtime options: - -```go -chat.RuntimeOptions{ - DedupeTTL: 24 * time.Hour, - ThreadLockTTL: 2 * time.Minute, - Concurrency: chat.ConcurrencyDrop, -} -``` - -The runtime implements all five upstream-aligned strategies (ADR 0012): - -- `ConcurrencyDrop` (default): a lock conflict is acknowledged and dropped. -- `ConcurrencyQueue`: the newest follow-up waits for the in-flight handler; - superseded follow-ups are observable, never silent. -- `ConcurrencyDebounce`: each new event resets a `DebounceInterval` timer; only - the final event in a quiet period dispatches. Requires deferred dispatch. - Coalescing (like queue supersession) is per runtime instance; instances - sharing a state are serialized by the thread lock, not coalesced. -- `ConcurrencyConcurrent`: no thread lock at all; every event dispatches in its - own execution, bounded by `MaxConcurrent`. -- `ConcurrencyBurst`: routed events for a scope collect for a `BurstWindow`, - then dispatch as one batch under a single lock hold, each member in join - order with its own `DetachTimeout` budget. Requires deferred dispatch. - Like queue supersession and debounce coalescing, batching is per runtime - instance. - -The one remaining ADR 0012 concept — the force/steerability -(`onLockConflict`) preemption hook — is rejected for v0.x per ADR 0015; the -names stay reserved behind that ADR's formal-design bar. - -`LockScope` chooses what the lock guards: per thread (default) or per channel -(`LockScopeChannel`) for platforms whose model needs channel-wide ordering. - -A deferred handler whose lock lease is lost mid-run (released elsewhere, -expired, or no longer refreshable) is cancelled with `chat.ErrPreempted` as -its context cause rather than running on unserialized. - -Thread locks use token-owned lock leases. Release and extend operations must -verify the token so an expired handler cannot release or extend another -handler's newer lock. - -Lock conflict behavior defaults to acknowledge-and-drop. A lock conflict is -observed as unhandled runtime contention and should not trigger platform retry. - -## Messages - -The portable outbound surface is intentionally small: - -```go -ev.Thread.Post(ctx, chat.Text("plain text")) -ev.Thread.Post(ctx, chat.Markdown("**portable** formatting intent")) -``` - -`Text` means no formatting intent. `Markdown` means conservative CommonMark -formatting intent, not Slack `mrkdwn`, GitHub-flavored Markdown, or a -platform-native rich payload. Adapters may render, translate, or degrade it. -The Slack adapter uses Slack's `markdown_text` posting field for Markdown -messages rather than converting CommonMark to `mrkdwn` itself. - -Posting returns `SentMessage` identity. Edit, delete, reactions, files, and -typed rich payload builders are outside the portable surface. Platform-native -content and Slack modal opening are reachable deliberately through typed -adapter access (see [Command And Interaction Events](#command-and-interaction-events)). +Point your Slack app's Event Subscriptions request URL at +`https://YOUR_HOST/webhooks/slack`, mention the bot, and it replies in a +thread. The [tutorial](docs/tutorials/slack-bot.md) walks through the Slack +app setup in under 30 minutes using +[`examples/slack-hello-world`](examples/slack-hello-world/), the same bot +with server timeouts and environment checks. -## Ephemeral Messages +## Install -Ephemeral delivery is required for the Slack-first slice: +Chat SDK Go requires Go 1.26.3 or newer. The core module contains the +runtime, the Slack and Linear adapters, and the memory state backend: -```go -sent, err := ev.Thread.PostEphemeral(ctx, ev.Message.Author, chat.Text( - "Please link your account.", -), chat.EphemeralOptions{ - FallbackToDM: true, -}) +```sh +go get github.com/coder/chat ``` -An ephemeral message is not a normal thread reply and must never fall back to a -public reply. - -Fallback is explicit: - -- If native ephemeral delivery works, the adapter sends native ephemeral output. -- If native ephemeral delivery is unavailable and `FallbackToDM` is true, the - adapter may deliver through a direct message thread. -- If native ephemeral delivery is unavailable and `FallbackToDM` is false, the - operation returns no delivered message. -- If fallback is requested but impossible, the operation returns an error. +The durable state backends are separate modules, so applications that only +use the core do not pull their dependencies: -Ephemeral behavior is modeled as an optional adapter capability through small Go -interfaces, not string capability flags. - -## Message History - -Message history is application-owned. The runtime owns coordination state -(subscriptions, dedupe, locks), not a message store; durable transcripts, LLM -context windows, summaries, and RAG corpora are Thread Application State kept in -the application's own storage keyed by Thread ID. - -For the common "fetch recent platform messages for this thread" case, an adapter -may implement the `HistoryReader` Optional Capability, reached through typed -adapter access like other capabilities: - -```go -hr, ok := chat.AdapterAs[interface{ chat.HistoryReader }](bot, "slack") -if ok { - msgs, err := hr.ReadHistory(ctx, ev.Thread.ID(), chat.HistoryQuery{Limit: 20}) - // The app decides what, if anything, to persist as Thread Application State. -} +```sh +go get github.com/coder/chat/state/redis +go get github.com/coder/chat/state/postgres +go get github.com/coder/chat/state/nats ``` -`HistoryReader` is a thin live read-through, not history persistence: - -- `ReadHistory` reads the platform API directly, keyed by the opaque Thread ID. - It performs no runtime storage: no Runtime State writes, no dedupe, no caching. -- It is reached only through `chat.AdapterAs`; there is no `bot.ReadHistory` and no - `Thread.History`, and history is never a routing hook input or auto-fetched - during dispatch. -- Absence of the capability is the explicit unsupported result (`ok == false`), - never an empty slice that masquerades as "no history". -- Ordering, pagination, and page-size clamping are adapter-owned and documented in - each adapter's GoDoc. The Slack adapter returns messages newest-first, pages - toward older messages via a `Before` cursor that is a `Message.ID`, and clamps - the limit to Slack's maximum. The Linear adapter reads agent-session threads - from the session's agent activities and issue-comment threads from the root - comment and its replies, with the same newest-first ordering and `Before` - cursor semantics, clamped to Linear's maximum page size. -- Long fetches run after ack via the ack-then-work seam; the runtime never fetches - history on the inbound request path. - -This deliberately diverges from Vercel Chat SDK's end-to-end stored-history model: -persistence of conversation content stays an application concern. - -## Actors And Identity - -`Actor` is scoped by adapter and platform tenant. Raw Slack user IDs are not -global identities. - -Bot-ness is explicit: - -```go -type BotKind int - -const ( - BotUnknown BotKind = iota - BotHuman - BotBot -) -``` +## Features + +- **Thread-scoped conversations.** `OnNewMention` and `OnSubscribedMessage` + route by thread; subscriptions are explicit and survive restarts on a + durable backend. Start with the [tutorial](docs/tutorials/slack-bot.md). +- **Coordination state you already run.** Subscriptions, dedupe marks, and + token-owned lock leases on memory, Redis, Postgres, or NATS JetStream, + behind one contract and one conformance suite — + [choose a state backend](docs/how-to/choose-a-state-backend.md). +- **Ack-then-work dispatch.** Acknowledge the webhook first, run the handler + on a detached context with automatic lock renewal, bound in-flight work + with an admission cap, and pick from five concurrency strategies (drop, + queue, debounce, concurrent, burst) — + [defer long-running work](docs/how-to/deferred-dispatch.md). +- **Slash commands and interactive components.** Commands and button clicks + are first-class events with their own hooks; Block Kit content and modals + go through typed adapter access — + [slash commands](docs/how-to/slash-commands.md), + [interactive components](docs/how-to/interactive-components.md). +- **Multi-tenant installs.** Serve many workspaces or organizations from one + deployment with an application-implemented `InstallStore`; OAuth flows + stay yours — [multi-tenant installs](docs/how-to/multi-tenant-install.md). +- **Linear agent sessions.** Thoughts, responses, actions, elicitations, + plans, and generic issue comments — + [run Linear agent sessions](docs/how-to/linear-agent-sessions.md). +- **Rate limits handled in the adapter.** Slack and Linear API calls retry + with `Retry-After` and bounded backoff and surface a typed `RateLimited` + error when they give up — + [adapter capability status](docs/reference.md#adapter-capability-status). +- **Observability without a dependency.** Structured `slog` logging plus an + optional `Observer` seam for counters and per-dispatch spans; no + OpenTelemetry in the core import graph — + [observability](docs/reference.md#observability). +- **Message history read-through.** `HistoryReader` fetches recent platform + messages for a thread on demand; what you persist is up to you — + [message history](docs/reference.md#message-history). + +## Adapters + +Adapters are either `supported` — production-grade, with hardening test +suites, rate-limit handling, multi-tenant installs, and documentation — or +`experimental` — implemented and tested, but the platform surface, the +adapter API, or both may still change. -Self-authored bot messages are ignored before subscription or mention routing. +| Adapter | Tier | Notes | +| --- | --- | --- | +| Slack (`adapters/slack`) | `supported` | Hardening tests for rate-limit retry ([ADR 0005](docs/adr/0005-rate-limit-handling.md)), multi-tenant installs ([ADR 0006](docs/adr/0006-multi-tenant-install.md)), history read-through ([ADR 0009](docs/adr/0009-message-history.md)), and interactivity. No live end-to-end Slack test runs in CI. | +| Linear (`adapters/linear`) | `experimental` | Fully implemented and hardened (agent sessions, generic comments, rate-limit retry, multi-tenant, history read-through), but the upstream Linear agent API is itself in developer preview and [capability gaps remain](docs/linear-agent-capabilities.md). | +| Microsoft Teams | spike | [ADR 0007](docs/adr/0007-teams-adapter.md) is a proposal gated on a live-tenant spike (draft [PR #4](https://github.com/coder/chat/pull/4), tracked in [#6](https://github.com/coder/chat/issues/6)). Not usable yet. | -Application identity is not part of the runtime. Account linking, login prompts, -pending auth flows, and product user records belong to the application. +## Documentation -## Adapter Access +Documentation follows [Diátaxis](https://diataxis.fr/); the +[docs index](docs/README.md) maps it all. -Normalized APIs should cover common flows. Platform-specific APIs are still -reachable through typed adapter access: +- **Tutorial**: [your first Slack bot](docs/tutorials/slack-bot.md) — zero to + a running bot in under 30 minutes. +- **How-to guides**: [state backends](docs/how-to/choose-a-state-backend.md), + [deferred dispatch](docs/how-to/deferred-dispatch.md), + [slash commands](docs/how-to/slash-commands.md), + [interactive components](docs/how-to/interactive-components.md), + [multi-tenant installs](docs/how-to/multi-tenant-install.md), and + [Linear agent sessions](docs/how-to/linear-agent-sessions.md). +- **Reference**: [runtime semantics and API reference](docs/reference.md) — + construction, webhooks, routing, dispatch, state, concurrency, messages, + history, adapter access, per-adapter capability status, and the testing + contract, plus [pkg.go.dev](https://pkg.go.dev/github.com/coder/chat) for + the GoDoc. +- **Explanation**: [architecture and design decisions](docs/explanation.md) + — an index over [`CONTEXT.md`](CONTEXT.md) and the [ADRs](docs/adr/), with + the design goals, the Vercel Chat SDK comparison, and the non-goals. -```go -slackAdapter, ok := chat.AdapterAs[*slack.Adapter](bot, "slack") -if !ok { - return errors.New("slack adapter is not registered") -} -``` +## Relationship To Vercel Chat SDK -Examples should prefer this helper over unchecked type assertions. - -## Slack Adapter Status - -The Slack adapter is the first `supported` adapter. The implementation covers: - -- single-install configuration -- multi-tenant installs via an application-implemented `InstallStore` (ADR 0006) -- signing secret verification -- URL verification -- bot identity discovery during adapter initialization -- supported-shape decoding with unknown-field tolerance -- message-created normalization -- direct-message normalization -- root-message thread rooting -- self-message filtering -- retry metadata observation -- thread replies -- plain text and portable markdown posting, using Slack's `markdown_text` field - for Markdown messages -- native ephemeral messages -- explicit ephemeral DM fallback -- slash commands as Command Events and `block_actions` as Interaction Events - (ADR 0003, ADR 0004) -- native Block Kit posting, modal open, and `response_url` responses via typed - adapter access -- Web API rate-limit retry with `Retry-After` handling, bounded backoff, and a - typed `RateLimited` error (ADR 0005) -- thread history read-through via the `HistoryReader` Optional Capability - (ADR 0009) - -The adapter uses local structs for the Slack payload shapes it supports, -preserves raw payload data as an escape hatch, and validates required fields -for supported event types. - -This is still not a complete Slack product surface: see -[Intentional Gaps](#intentional-gaps) for what is deliberately absent. - -## Linear Adapter Status - -The Linear adapter is `experimental`: the implementation is broad and -hardened, but the upstream Linear agent API is itself in developer preview, -so no production promises are made yet. The implementation covers: - -- single-install app-actor client credentials with granted-scope verification -- multi-tenant installs via an application-implemented `InstallStore`, with - per-install webhook secrets and credentials or pre-exchanged access tokens - (ADR 0006) -- webhook signing secret verification and timestamp replay checks -- app actor and organization identity discovery during adapter initialization -- Linear `AgentSessionEvent` created and prompted normalization, including - assignment/delegation-created sessions emitted by Linear -- generic issue/comment participation outside agent sessions, with a - thread-kind discriminator in the opaque thread ID (ADR 0013) -- source-comment-based event identity for dedupe -- tenant-correct opaque Linear thread IDs -- runtime self-message filtering through the discovered app actor identity -- thread handle reconstruction for stored Linear thread IDs -- the full agent activity surface through typed adapter access: thoughts, - responses, actions, elicitations, errors, and session updates with plans and - external URLs (ADR 0008) -- thread history read-through via the `HistoryReader` Optional Capability, - reading agent-session activities and issue-comment threads (ADR 0009) -- GraphQL rate-limit retry with a typed `RateLimited` error (ADR 0005) -- a `GraphQL` escape hatch and a `RawMessage` escape hatch (including the - user-initiated stop signal) -- plain text and portable markdown pass-through for Linear activity bodies -- one memory-backed hello-world example with setup and dogfooding instructions - -The Linear adapter follows the Slack adapter pattern: supported payload shapes are -modeled locally, low-level HTTP/GraphQL calls stay private, and public -platform-specific behavior is exposed through narrow methods rather than a raw -Linear client. - -For the tracked list of Linear agent APIs and best-practice behaviors that are -not yet implemented, see -[`docs/linear-agent-capabilities.md`](docs/linear-agent-capabilities.md). +Chat SDK Go follows [Vercel Chat SDK](https://chat-sdk.dev/)'s conversation +model — adapters, normalized events, threads, subscriptions, thread-scoped +replies — where it maps cleanly to Go. It is not a TypeScript API port: +hooks are single-slot, construction is fail-fast, subscriptions are explicit, +and message history is application-owned. The concept-by-concept status map +is in [docs/explanation.md](docs/explanation.md#vercel-chat-sdk-alignment). ## Non-Goals -These are deliberate design boundaries, each recorded in an ADR. Most are -permanent ownership boundaries; streaming is the one explicitly *deferred* -boundary — out of the core runtime today, not foreclosed forever: - -- **Streaming token transport in the core runtime** — [ADR 0011](docs/adr/0011-resumable-streaming.md) - defers token streaming and pub/sub transports out of core (without - foreclosing a future optional capability); long generation is ack-then-work - ([ADR 0002](docs/adr/0002-async-dispatch.md)) posting one finished message. -- **LLM routing and prompt orchestration** — the runtime coordinates - conversations; LLM calls, prompt assembly, and generation pipelines are - application concerns inside handlers ([ADR 0011](docs/adr/0011-resumable-streaming.md) - classifies generation and stream persistence as app/LLM concerns; - [`CONTEXT.md`](CONTEXT.md) defines the runtime boundary). -- **A generative-UI card DSL** — [ADR 0004](docs/adr/0004-interactive-components.md) - rejected a cross-platform card model as lossy; platform-native payloads ship - opaquely via `NativeContentPoster` instead. -- **RAG and embeddings** — [ADR 0009](docs/adr/0009-message-history.md) keeps - embeddings, summaries, and RAG corpora as Thread Application State in the - application's own database keyed by Thread ID. -- **Durable transcript persistence in `chat.State`** — [ADR 0009](docs/adr/0009-message-history.md) - rejected baking a message store into runtime state; `chat.State` stays - subscriptions, dedupe, and locks. -- **App-user auth orchestration** — [ADR 0006](docs/adr/0006-multi-tenant-install.md) - scopes the install store to platform-tenant credentials; account linking, - login prompts, and OAuth web flows are Application Identity and stay - app-owned. - -## Intentional Gaps - -These are not bugs; they are things the current scope deliberately does not -include: - -- no TypeScript API compatibility -- no full Vercel Chat SDK feature parity -- no multiple handlers per routing hook -- no lazy runtime initialization -- no Linear personal API key mode, and no single-install static access token - (pre-exchanged access tokens are supported through the multi-tenant - `InstallStore`) -- no Linear streaming, reactions, or Markdown conversion -- no built-in OAuth web flow: authorize/callback/token-exchange routes and - install storage are application-owned (ADR 0006) -- no live Slack end-to-end test in CI -- no dedicated `OnDirectMessage` hook -- no public proactive `OpenDM`, except adapter behavior needed for explicit - ephemeral fallback -- no pattern handlers -- no middleware -- no history persistence APIs: `HistoryReader` is a storage-free live - read-through, implemented by the Slack and Linear adapters -- no thread application state APIs -- no JSX cards, files, or typed Block Kit / Adaptive Card payload builders - (native Block Kit content ships as an opaque payload via `NativeContentPoster`) -- no Slack shortcuts or Block Kit workflow steps (block_actions buttons and menus - are routed as Interaction Events) -- no synchronous modal `view_submission` response (modal-open via `views.open` - ships; the synchronous `response_action` is incompatible with ack-then-work and - is deferred) -- no edit, delete, reaction, or other outbound mutation APIs beyond what a native - interaction response needs -- no bundled metrics framework, exporters, or scrape endpoint (an optional no-op - `Observer` seam is provided; OpenTelemetry stays out of the core import graph) -- no built-in HTTP server or router integrations -- no adapter marketplace/package conventions - -## Testing Contract - -Tests should verify external behavior and public contracts, not private -implementation details. - -Required test families: - -- runtime construction and shutdown -- handler registration and replacement -- routing order and no-op missing handlers -- explicit subscription and unsubscribe -- direct-message implicit mention routing -- self-message filtering -- accepted, ignored, rejected, duplicate, and lock-conflict events -- state conformance across memory, Redis, Postgres, and NATS -- token-owned lock lease acquire, release, extend, expiry, and stale release -- Slack signature verification and URL verification -- Slack golden payload normalization -- thread ID construction and validation -- thread handle reconstruction -- text, markdown, sent message, ephemeral, and ephemeral fallback posting -- typed adapter access -- README and GoDoc coverage for intentional Vercel differences - -Local test commands: - -```sh -mise run test -mise run test:root -mise run test:adapters -mise run test:examples -mise run test:nats -mise run test:postgres -mise run test:redis -``` - -`mise run test` is a composite task that runs the root module tests, -`test:adapters`, and `test:examples`. The adapter-focused task also exercises -the NATS, Redis, and Postgres state modules. The Redis and Postgres state -tests use Testcontainers for real backend coverage and skip when Docker is -unavailable; the NATS tests run against an embedded JetStream server. +Each of these is a recorded decision, not a missing feature. The full list +with the ADR behind each is in +[docs/explanation.md](docs/explanation.md#non-goals); the scope exclusions +are in [intentional gaps](docs/explanation.md#intentional-gaps). + +- **Streaming token transport in the core** — deferred, not foreclosed + ([ADR 0011](docs/adr/0011-resumable-streaming.md)); long generation is + ack-then-work posting one finished message. +- **LLM orchestration** — prompts, model calls, and generation pipelines live + in your handlers. +- **A cross-platform card DSL** — platform-native payloads ship opaquely via + `NativeContentPoster`. +- **Transcript storage, RAG, and embeddings** — message history is + application-owned; `chat.State` holds subscriptions, dedupe marks, and + locks only. +- **App-user auth and OAuth web flows** — install storage and account linking + stay app-owned. + +## Status + +The current release is [v0.2.0](https://github.com/coder/chat/releases/latest). +The public Go API may change before 1.0; the +[release notes](https://github.com/coder/chat/releases) describe what changed +in each version. Bug reports and feature requests are tracked in +[GitHub issues](https://github.com/coder/chat/issues). diff --git a/docs/README.md b/docs/README.md index bc28422..1c7d78b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -30,8 +30,10 @@ Task-oriented guides for people already running a bot. ## Reference -- [API and package reference](reference.md) — pkg.go.dev pointers, module - layout, and per-adapter capability status. +- [Reference](reference.md) — module layout and pkg.go.dev pointers, the + runtime's semantics by concept (construction, webhooks, routing, dispatch, + state, concurrency, messages, history, actors, adapter access), + per-adapter capability status, the examples, and the testing contract. - [Linear agent capability gaps](linear-agent-capabilities.md) — tracked list of Linear agent APIs the adapter does not yet wrap. @@ -39,7 +41,9 @@ Task-oriented guides for people already running a bot. - [Architecture and design decisions](explanation.md) — an index over [`CONTEXT.md`](../CONTEXT.md) (the ubiquitous language and architecture - document) and the [ADRs](adr/) that record every significant decision. + document) and the [ADRs](adr/) that record every significant decision, + plus the design goals, the Vercel Chat SDK concept map, the non-goals, and + the intentional gaps. ## Non-User Documentation diff --git a/docs/explanation.md b/docs/explanation.md index 155275f..d432951 100644 --- a/docs/explanation.md +++ b/docs/explanation.md @@ -1,7 +1,9 @@ # Architecture And Design Decisions Chat SDK Go's design is documented in two places, and this page is the index -over both: +over both. It also states the [design goals](#design-goals), maps the +project against [Vercel Chat SDK](#vercel-chat-sdk-alignment), and lists the +[non-goals](#non-goals) and [intentional gaps](#intentional-gaps). - [`CONTEXT.md`](../CONTEXT.md) — the ubiquitous language and architecture document. It defines every domain term precisely (with the synonyms to @@ -70,3 +72,129 @@ same dispatch spine. conversation model is the precedent; its TypeScript API shapes are not. Where Go idioms or operational safety argue otherwise, this SDK deliberately diverges and documents the divergence. + +## Design Goals + +- Go-native API built around `context.Context`, `net/http`, small + interfaces, and explicit errors. +- Slack-first vertical slice before claiming multi-platform portability. +- Required runtime state for subscriptions, dedupe, and locks: memory for + tests and local development; Redis, Postgres, or NATS JetStream for + horizontally scaled production deployments. +- Thread-oriented application code: handle a message, subscribe the thread, + reply to the thread. +- Platform escape hatches without making raw platform structs the normal + API. +- Vercel Chat SDK behavior as the default precedent unless it is + non-idiomatic in Go or outside the documented scope. + +## Vercel Chat SDK Alignment + +Chat SDK Go follows Vercel Chat SDK's conversation semantics where they fit +Go, built outward from a production-shaped Slack slice. It is +not a TypeScript API port and does not promise full feature parity. For +readers who know Vercel Chat SDK, this is the concept-by-concept status map: + +| Vercel Chat SDK concept | Chat SDK Go status | +| --- | --- | +| `Chat` runtime | Implemented as `chat.Chat` | +| Platform adapters | Slack (supported) and Linear (experimental) implemented; Teams is a spike | +| Normalized events and thread-scoped replies | Implemented | +| `onNewMention` | Implemented as `OnNewMention` | +| `onSubscribedMessage` | Implemented as `OnSubscribedMessage` | +| Thread subscriptions | Implemented with explicit `Thread.Subscribe` / `Thread.Unsubscribe` | +| Runtime state adapters | Memory, Redis, Postgres, and NATS JetStream implemented | +| Direct messages | Routed as implicit new mentions, then subscribed messages | +| Ephemeral messages | Slack native ephemeral plus explicit DM fallback | +| Thread handle reconstruction | Implemented with `Chat.Thread` | +| AI streaming responses | Deferred from core, not foreclosed ([ADR 0011](adr/0011-resumable-streaming.md)); long generation uses ack-then-work | +| Slash commands | Implemented as `OnCommand` Command Events (Slack) | +| Interactive components (buttons, menus) | Implemented as `OnInteraction` `block_actions` (Slack) | +| Native rich content (Block Kit) | Implemented as the `NativeContentPoster` Optional Capability (Slack) | +| Modal open (`views.open`) | Implemented as a Slack adapter Optional Capability | +| Modal `view_submission` synchronous response | Deferred (incompatible with ack-then-work) | +| Cards, JSX-style cards, native payload builders | Not implemented | +| Pattern handlers | Not implemented | +| Observability metrics/tracing | Optional `Observer` seam, no-op default, no OTel dependency in core | +| Message history persistence | App-owned (Thread Application State); thin live read-through via the `HistoryReader` Optional Capability (Slack, Linear) | +| AI-message conversion helpers | Not implemented | +| Multiple production adapters | Slack is the only `supported` adapter; Linear is `experimental` | +| Middleware | Not implemented | + +The behavioral differences that matter when porting handler code — single-slot +hooks, fail-fast construction, explicit subscriptions, application-owned +history — are documented on the affected symbols' GoDoc and in the +[reference](reference.md). + +## Non-Goals + +These are deliberate design boundaries, each recorded in an ADR. Most are +permanent ownership boundaries; streaming is the one explicitly *deferred* +boundary — out of the core runtime today, not foreclosed forever. + +- **Streaming token transport in the core runtime** — + [ADR 0011](adr/0011-resumable-streaming.md) defers token streaming and + pub/sub transports out of core (without foreclosing a future optional + capability); long generation is ack-then-work + ([ADR 0002](adr/0002-async-dispatch.md)) posting one finished message. +- **LLM routing and prompt orchestration** — the runtime coordinates + conversations; LLM calls, prompt assembly, and generation pipelines are + application concerns inside handlers + ([ADR 0011](adr/0011-resumable-streaming.md) classifies generation and + stream persistence as app/LLM concerns; [`CONTEXT.md`](../CONTEXT.md) + defines the runtime boundary). +- **A generative-UI card DSL** — + [ADR 0004](adr/0004-interactive-components.md) rejected a cross-platform + card model as lossy; platform-native payloads ship opaquely via + `NativeContentPoster` instead. +- **RAG and embeddings** — [ADR 0009](adr/0009-message-history.md) keeps + embeddings, summaries, and RAG corpora as Thread Application State in the + application's own database keyed by Thread ID. +- **Durable transcript persistence in `chat.State`** — + [ADR 0009](adr/0009-message-history.md) rejected baking a message store + into runtime state; `chat.State` stays subscriptions, dedupe, and locks. +- **App-user auth orchestration** — + [ADR 0006](adr/0006-multi-tenant-install.md) scopes the install store to + platform-tenant credentials; account linking, login prompts, and OAuth web + flows are Application Identity and stay app-owned. + +## Intentional Gaps + +These are not bugs; they are things the current scope deliberately does not +include: + +- no TypeScript API compatibility +- no full Vercel Chat SDK feature parity +- no multiple handlers per routing hook +- no lazy runtime initialization +- no Linear personal API key mode, and no single-install static access token + (pre-exchanged access tokens are supported through the multi-tenant + `InstallStore`) +- no Linear streaming, reactions, or Markdown conversion +- no built-in OAuth web flow: authorize/callback/token-exchange routes and + install storage are application-owned + ([ADR 0006](adr/0006-multi-tenant-install.md)) +- no live Slack end-to-end test in CI +- no dedicated `OnDirectMessage` hook +- no public proactive `OpenDM`, except adapter behavior needed for explicit + ephemeral fallback +- no pattern handlers +- no middleware +- no history persistence APIs: `HistoryReader` is a storage-free live + read-through, implemented by the Slack and Linear adapters +- no thread application state APIs +- no JSX cards, files, or typed Block Kit / Adaptive Card payload builders + (native Block Kit content ships as an opaque payload via + `NativeContentPoster`) +- no Slack shortcuts or Block Kit workflow steps (`block_actions` buttons and + menus are routed as Interaction Events) +- no synchronous modal `view_submission` response (modal open via + `views.open` ships; the synchronous `response_action` is incompatible with + ack-then-work and is deferred) +- no edit, delete, reaction, or other outbound mutation APIs beyond what a + native interaction response needs +- no bundled metrics framework, exporters, or scrape endpoint (an optional + no-op `Observer` seam is provided; OpenTelemetry stays out of the core + import graph) +- no built-in HTTP server or router integrations +- no adapter marketplace/package conventions diff --git a/docs/how-to/deferred-dispatch.md b/docs/how-to/deferred-dispatch.md index 93e9091..75df8a1 100644 --- a/docs/how-to/deferred-dispatch.md +++ b/docs/how-to/deferred-dispatch.md @@ -16,13 +16,14 @@ retry or time out. runtime-managed detached work context, concurrently with the webhook response — the acknowledgement no longer waits on your handler (though the tail may begin before the 2xx is actually written). The runtime renews the - thread lock lease in the background while the handler runs. If the state - backend fails to extend the lease (an error or a lost lease), renewal - stops and is logged/observed, but the handler keeps running **without - exclusivity** — after the original `ThreadLockTTL` expires, another event - on the same thread can acquire the lock and run concurrently. Long - handlers should therefore be idempotent or tolerate overlap under state - backend failures. + thread lock lease in the background while the handler runs. If the lease + is lost — the state backend fails to extend it, it expired, or another + runtime instance released it — the handler's context is cancelled with + `chat.ErrPreempted` as its cause (`context.Cause(ctx)`) rather than + running on without exclusivity. Cancellation is cooperative: a handler + that ignores its context keeps running, so use the `ctx` you are given + for every call and treat `ErrPreempted` as "stop, someone else may now + own this thread". ## Enable It @@ -80,8 +81,10 @@ bot, err := chat.New(ctx, cap is rejected with `chat.ErrAdmissionRejected` **before** the ack and **before** dedupe marking; the adapter maps that to a retry-inducing 503 for platform-redelivered shapes (Slack Events API callbacks, Linear webhooks) and - a truthful busy signal for direct invocations (Slack slash commands and - interactivity). The optional `MaxDetachedPerTenant` additionally caps one + a truthful busy signal for direct invocations Slack does not redeliver: a + slash command gets a 200 with a visible "at capacity" message, a + `block_actions` click gets a 503 that Slack surfaces as a warning on the + component. The optional `MaxDetachedPerTenant` additionally caps one installation's share through the same rejection path. Sizing guidance lives on the `MaxDetached` GoDoc. diff --git a/docs/reference.md b/docs/reference.md index 2fb4a33..653a366 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -1,8 +1,11 @@ -# API And Package Reference +# Reference -The API reference is the GoDoc. Every package carries package-level +The API reference is the GoDoc: every package carries package-level documentation (`doc.go`), and the intentional differences from Vercel Chat -SDK are documented directly on the symbols they affect. +SDK are documented on the symbols they affect. This page covers what GoDoc +does not: the module layout, the runtime's semantics organized by concept, +per-adapter capability status, the runnable examples, and the testing +contract. ## Modules And Packages @@ -18,7 +21,8 @@ SDK are documented directly on the symbols they affect. Redis, Postgres, and NATS state live in separate Go modules so applications that only use core, Slack, or memory state do not pull their dependencies. -The repository uses `go.work` for local development across all modules. +The repository uses `go.work` for local development across the root module, +the state modules, and the example modules. To browse the reference locally without pkg.go.dev: @@ -27,7 +31,7 @@ go doc github.com/coder/chat go doc github.com/coder/chat/adapters/slack ``` -## Where To Look For What +### Where To Look For What - **Runtime construction, hooks, dispatch, runtime options**: package `chat` (`chat.New`, `Chat.OnNewMention`, `Chat.OnSubscribedMessage`, @@ -43,6 +47,444 @@ go doc github.com/coder/chat/adapters/slack - **State contract**: package `chat` (`State`) with implementations in the four `state/*` packages. +## Core Model + +`Chat` is the runtime. It owns adapter registration, runtime state, handler +registration, webhook mounting, dispatch, dedupe, locking, and shutdown. + +A `Platform Adapter` is a platform boundary. It verifies inbound webhooks, +normalizes platform payloads, renders outbound messages, and exposes +platform-specific APIs through typed adapter access. It does not own +application routing. + +`Event` is the normalized inbound envelope. A `Message` is one payload type +inside an event, not the name for every inbound platform occurrence: a slash +command and a button click are events too (see +[Command And Interaction Events](#command-and-interaction-events)). + +`MessageEvent` is the handler input for the message routing hooks. It carries +the normalized event, thread, and message together. + +`Thread` is the stable conversation address used for routing, subscription, +and replies. In Slack, a root channel message becomes a thread rooted at that +message timestamp, not the entire channel. + +`ThreadID` is opaque and adapter-produced. It includes adapter identity and +enough platform tenant and routing context to avoid collisions across +workspaces, channels, and platforms. Application code may store and pass it +around, but must not build it manually. + +Thread handles can be reconstructed from a stored `ThreadID` for +out-of-webhook work such as reminders or proactive follow-ups: + +```go +thread, err := bot.Thread(ctx, threadID) +if err != nil { + return err +} + +_, err = thread.Post(ctx, chat.Text("Reminder")) +``` + +The runtime decodes the adapter prefix, asks the adapter to validate the +thread ID, and returns an error for unknown adapters or invalid IDs. + +## Runtime Construction + +Construction is fail-fast: + +```go +bot, err := chat.New(ctx, + chat.WithState(state), + chat.WithAdapter(slackAdapter), + chat.WithLogger(slog.Default()), + chat.WithRuntimeOptions(chat.DefaultRuntimeOptions()), +) +if err != nil { + return err +} +defer func() { + if err := bot.Shutdown(context.Background()); err != nil { + slog.Error("chat shutdown failed", "error", err) + } +}() +``` + +`chat.New` requires a `State` and at least one adapter, validates the runtime +options, and initializes every adapter (which is where the Slack adapter +discovers its own bot identity) before any webhook is served. A failing +adapter initialization shuts down the adapters registered so far and returns +the joined error. This is an intentional difference from Vercel Chat +SDK, which initializes lazily on first use. + +Options: + +- `WithState(State)` — required. The runtime never silently creates memory + state; see [Runtime State](#runtime-state). +- `WithAdapter(Adapter)` — at least one; adapter names must be unique. +- `WithLogger(*slog.Logger)` — defaults to `slog.Default()`. +- `WithObserver(Observer)` — optional; see [Observability](#observability). +- `WithRuntimeOptions(RuntimeOptions)` — replaces the whole options struct + (it does not merge), so start from `chat.DefaultRuntimeOptions()`. See + [Dedupe, Locks, And Concurrency](#dedupe-locks-and-concurrency). + +`Shutdown(ctx)` is idempotent. It cancels detached work, runs every +adapter's cleanup hook before state cleanup, and returns joined errors if +cleanup fails. + +## Webhooks + +The runtime exposes `net/http` handlers and does not own the HTTP server: + +```go +handler, err := bot.Webhook("slack") +if err != nil { + return err +} + +http.Handle("/webhooks/slack", handler) +``` + +Webhook lookup is fallible: a misspelled adapter name is a startup error, not +a production 404. + +Adapters own platform handshakes. For Slack, the `url_verification` +challenge is answered inside the Slack webhook handler and never reaches +application handlers. + +## Routing + +The runtime has two message routing hooks: + +```go +bot.OnNewMention(func(context.Context, *chat.MessageEvent) error) +bot.OnSubscribedMessage(func(context.Context, *chat.MessageEvent) error) +``` + +Routing order: + +1. Ignore self-authored bot messages. +2. Route messages in subscribed threads to `OnSubscribedMessage`. +3. Route mentions in unsubscribed threads to `OnNewMention`. +4. Acknowledge and ignore any other valid platform event. + +Direct messages are implicit mentions: an unsubscribed direct message routes +to `OnNewMention`; once the thread is subscribed, later direct messages route +to `OnSubscribedMessage`. There is no dedicated direct-message hook. + +Handlers are single-slot per hook. Calling `OnNewMention` or +`OnSubscribedMessage` again atomically replaces the previous handler. A +missing handler is a no-op that still acknowledges the platform. This +intentionally differs from Vercel Chat SDK, which allows multiple handlers +per hook. + +Subscriptions are explicit: + +```go +if err := ev.Thread.Subscribe(ctx); err != nil { + return err +} +``` + +Replying to a new mention does not subscribe the thread. A subscription lasts +until `Thread.Unsubscribe`. + +### Command And Interaction Events + +A slash command and a button click are events, not messages. They ride the +same dispatch spine (dedupe by event identity, thread lock, self-filtering, +lock-conflict acknowledge-and-drop) but route to their own single-slot hooks: + +- `OnCommand(func(ctx, *chat.CommandEvent) error)` for Command Events (Slack + slash commands). Command-ness takes precedence over subscription state: a + command in a subscribed thread routes to `OnCommand`, never to + `OnSubscribedMessage`. A command does not auto-subscribe its thread. +- `OnInteraction(func(ctx, *chat.InteractionEvent) error)` for Interaction + Events: Slack `block_actions` on messages (button clicks, menu selections). + `block_actions` raised inside a modal view are not normalized and are + rejected before routing. + +Both hooks are single-slot and no-op when unset, like the message hooks; an +unset handler is still acknowledged. The platform acknowledgement is +adapter-owned: the Slack adapter returns an empty 2xx and preserves +`response_url` and `trigger_id` on the `Raw` platform escape hatch. Under the +default synchronous dispatch the handler runs before that acknowledgement, so +long command or interaction work needs +[deferred dispatch](how-to/deferred-dispatch.md) to stay inside Slack's +3-second budget, and bots expecting commands or clicks mid-conversation +should select the `ConcurrencyQueue` strategy. + +Native command and interaction responses and Block Kit content are not part of +the portable `PostableMessage` surface (plain text and portable Markdown). +They are reached deliberately through typed adapter access: + +- `chat.NativeContentPoster.PostNative` posts opaque Block Kit blocks. A + `NativeContent` whose `Adapter` does not match the target adapter is an + error, never a silent portable downgrade. +- The Slack adapter's `OpenModalFromRaw` (and `OpenModal` for callers holding + a `trigger_id`) opens a modal via `views.open`. The synchronous modal + `view_submission` response is deferred because it is incompatible with + ack-then-work; the adapter acknowledges and drops `view_submission` + payloads. +- The Slack adapter's `RespondURL` posts to a preserved `response_url`. + +The [slash commands](how-to/slash-commands.md) and +[interactive components](how-to/interactive-components.md) guides show the +Slack configuration and handler patterns. + +## Dispatch And Acknowledgement + +The default dispatch mode is synchronous (`DispatchSync`): handlers run on the +inbound webhook request context before the platform acknowledgement. For +long-running work, opt in to `DispatchDeferred` (ack-then-work, +[ADR 0002](adr/0002-async-dispatch.md)): the dedupe and lock prelude runs +before the acknowledgement, then the handler runs on a detached work context +with automatic lock lease renewal, bounded by `DetachTimeout`. Under deferred +dispatch `MaxDetached` bounds admitted-but-incomplete deliveries; a delivery +arriving at the bound is rejected with `chat.ErrAdmissionRejected` before +acknowledgement and before dedupe marking ([ADR 0015](adr/0015-runtime-coordination.md)). +The [deferred dispatch guide](how-to/deferred-dispatch.md) covers enabling +it and writing handlers for the detached context. + +Once a webhook is verified and normalized into an accepted event, handler +errors are logged and observed but the event is still acknowledged to the +platform. This avoids platform retry storms after partial side effects such as +posting a message. + +Invalid signatures and malformed requests are rejected. Valid but unsupported +platform events are acknowledged and ignored. + +### Observability + +Logging is structured `slog` through `WithLogger`. The optional +`WithObserver(Observer)` seam ([ADR 0010](adr/0010-observability.md)) adds +counter-style point events (dedupe hit, lock conflict, ignored event by +reason, handler error, lock-release failure, admission rejection, adapter +call, rate limit) and a per-dispatch span with a terminal outcome +(`handled`, `ignored`, `dropped-lock-conflict`, `duplicate`, `error`, +`preempted`, `admission-rejected`). The default is a no-op observer, so an +unconfigured runtime behaves exactly as without the seam. + +The core imports no OpenTelemetry, Prometheus, or statsd. Attribute keys are +a closed, low-cardinality set (`adapter`, `route`, `reason`, `outcome`, +`tenant`) and never carry thread IDs, message text, or raw actor IDs. +Observer calls are panic-safe: a broken observer can never fail an accepted +event or alter acknowledgement. Under deferred dispatch the span follows the +detached work context, so ack-then-work latency is measured to handler +completion. + +## Runtime State + +State is required. The runtime never silently creates memory state. + +Runtime state is coordination state: + +- subscribed thread membership +- event dedupe marks +- thread lock leases + +Runtime state is not product state. Store application workflow data in your +own database keyed by `ThreadID`. + +The `chat.State` contract is small — subscription membership, `MarkEvent` +for dedupe, and token-owned `AcquireLock` / `ExtendLock` / `ReleaseLock` — +and four implementations ship: + +- `state/memory`: tests and local development, in the root module. +- `state/redis`, `state/postgres`, `state/nats`: production and horizontally + scaled deployments, each in its own module. + +All backends pass the same conformance suite. The +[state backend guide](how-to/choose-a-state-backend.md) compares them and +covers namespacing when several bots share one backend. + +## Dedupe, Locks, And Concurrency + +Event dedupe uses event identity, not delivery retry metadata. Slack retry +headers are logged as retry metadata but are not part of the dedupe key. + +Default runtime options (`chat.DefaultRuntimeOptions()`): + +```go +chat.RuntimeOptions{ + DedupeTTL: 24 * time.Hour, + ThreadLockTTL: 2 * time.Minute, + Concurrency: chat.ConcurrencyDrop, + Dispatch: chat.DispatchSync, + LockScope: chat.LockScopeThread, + MaxDetached: 1024, +} +``` + +The runtime implements all five upstream-aligned concurrency strategies +([ADR 0012](adr/0012-concurrency-strategy.md)): + +- `ConcurrencyDrop` (default): a lock conflict is acknowledged and dropped. +- `ConcurrencyQueue`: the newest follow-up waits for the in-flight handler; + superseded follow-ups are observable, never silent. +- `ConcurrencyDebounce`: each new routed event supersedes the previous + waiter; only the final event in a `DebounceInterval` quiet period + dispatches, and superseded events are observable. Requires deferred + dispatch. +- `ConcurrencyConcurrent`: no thread lock at all; every event dispatches in + its own execution, bounded by `MaxConcurrent`. +- `ConcurrencyBurst`: routed events for a scope collect for a `BurstWindow`, + then dispatch as one batch under a single lock hold, each member in join + order with its own `DetachTimeout` budget; `MaxBurstBatch` optionally seals + a full window early. Requires deferred dispatch. + +Queue supersession, debounce coalescing, and burst batching are per runtime +instance. Instances sharing a state are serialized by the thread lock, not +coalesced ([ADR 0015](adr/0015-runtime-coordination.md)). The +force/steerability (`onLockConflict`) preemption hook from ADR 0012 is +rejected for v0.x by ADR 0015; the names stay reserved behind that ADR's +formal-design bar. + +`LockScope` chooses what the lock guards: per thread (`LockScopeThread`, the +default) or per channel (`LockScopeChannel`) for platforms whose model needs +channel-wide ordering. + +Thread locks are token-owned lock leases. Release and extend operations +verify the token, so an expired handler cannot release or extend another +handler's newer lock. A deferred handler whose lock lease is lost mid-run +(released elsewhere, expired, or no longer refreshable) is cancelled with +`chat.ErrPreempted` as its context cause rather than running on +unserialized. + +Lock conflict behavior defaults to acknowledge-and-drop. A lock conflict is +observed as runtime contention and does not trigger a platform retry. + +## Messages + +The portable outbound surface is intentionally small: + +```go +ev.Thread.Post(ctx, chat.Text("plain text")) +ev.Thread.Post(ctx, chat.Markdown("**portable** formatting intent")) +``` + +`Text` means no formatting intent. `Markdown` means conservative CommonMark +formatting intent, not Slack `mrkdwn`, GitHub-flavored Markdown, or a +platform-native rich payload. Adapters may render, translate, or degrade it. +The Slack adapter posts Markdown messages through Slack's `markdown_text` +field rather than converting CommonMark to `mrkdwn` itself. + +Posting returns the `SentMessage` identity. Edit, delete, reactions, files, +and typed rich payload builders are outside the portable surface. +Platform-native content and Slack modal opening are reachable deliberately +through typed adapter access (see +[Command And Interaction Events](#command-and-interaction-events)). + +### Ephemeral Messages + +```go +sent, err := ev.Thread.PostEphemeral(ctx, ev.Message.Author, chat.Text( + "Please link your account.", +), chat.EphemeralOptions{ + FallbackToDM: true, +}) +``` + +An ephemeral message is not a normal thread reply and never falls back to a +public reply. Fallback is explicit: + +- If native ephemeral delivery works, the adapter sends native ephemeral + output. +- If native ephemeral delivery is unavailable and `FallbackToDM` is true, the + adapter may deliver through a direct message thread. +- If native ephemeral delivery is unavailable and `FallbackToDM` is false, the + operation returns no delivered message. +- If fallback is requested but impossible, the operation returns an error. + +Ephemeral delivery is an optional adapter capability expressed through a +small Go interface (`EphemeralPoster`), not a string capability flag. The +Slack adapter implements it; on adapters that do not, `PostEphemeral` returns +`chat.ErrUnsupportedCapability`. + +## Message History + +Message history is application-owned. The runtime owns coordination state +(subscriptions, dedupe, locks), not a message store; durable transcripts, LLM +context windows, summaries, and RAG corpora are Thread Application State kept +in the application's own storage keyed by Thread ID +([ADR 0009](adr/0009-message-history.md)). + +For the common "fetch recent platform messages for this thread" case, an +adapter may implement the `HistoryReader` Optional Capability, reached through +typed adapter access like other capabilities: + +```go +hr, ok := chat.AdapterAs[chat.HistoryReader](bot, "slack") +if ok { + msgs, err := hr.ReadHistory(ctx, ev.Thread.ID(), chat.HistoryQuery{Limit: 20}) + // The app decides what, if anything, to persist as Thread Application State. +} +``` + +`HistoryReader` is a thin live read-through, not history persistence: + +- `ReadHistory` reads the platform API directly, keyed by the opaque Thread + ID. It performs no runtime storage: no runtime state writes, no dedupe, no + caching. +- It is reached only through `chat.AdapterAs`; there is no `bot.ReadHistory` + and no `Thread.History`, and history is never a routing hook input or + auto-fetched during dispatch. +- Absence of the capability is the explicit unsupported result + (`ok == false`), never an empty slice that masquerades as "no history". +- Ordering, pagination, and page-size clamping are adapter-owned and + documented in each adapter's GoDoc. The Slack adapter returns messages + newest-first, pages toward older messages via a `Before` cursor that is a + `Message.ID`, and clamps the limit to Slack's maximum. The Linear adapter + reads agent-session threads from the session's agent activities and + issue-comment threads from the root comment and its replies, with the same + newest-first ordering and `Before` cursor semantics, clamped to Linear's + maximum page size. +- Long fetches belong after the acknowledgement, under deferred dispatch; the + runtime never fetches history on the inbound request path. + +This deliberately diverges from Vercel Chat SDK's end-to-end stored-history +model: persistence of conversation content stays an application concern. + +## Actors And Identity + +`Actor` is scoped by adapter and platform tenant. Raw Slack user IDs are not +global identities. + +Bot-ness is explicit: + +```go +type BotKind int + +const ( + BotUnknown BotKind = iota + BotHuman + BotBot +) +``` + +Self-authored bot messages are ignored before subscription or mention +routing. + +Application identity is not part of the runtime. Account linking, login +prompts, pending auth flows, and product user records belong to the +application. + +## Adapter Access + +Normalized APIs cover the common flows. Platform-specific APIs remain +reachable through typed adapter access: + +```go +slackAdapter, ok := chat.AdapterAs[*slack.Adapter](bot, "slack") +if !ok { + return errors.New("slack adapter is not registered") +} +``` + +`chat.AdapterAs[T](bot, name)` returns `(T, bool)`; prefer it over unchecked +type assertions. Optional capabilities (`NativeContentPoster`, +`HistoryReader`, `EphemeralPoster`) are reached the same way. + ## Adapter Capability Status Portable behavior (normalized events, thread routing, `Thread.Post`, @@ -64,18 +506,148 @@ platform-specific surfaces differ: | Agent activities (thought/response/action/elicitation/error) | n/a | Yes | | Session updates (plan, external URLs) | n/a | Yes (`UpdateSession`) | -For the tracked list of Linear agent APIs that are not yet wrapped in typed -helpers, see [linear-agent-capabilities.md](linear-agent-capabilities.md). +### Slack Adapter + +The Slack adapter (`adapters/slack`) is the `supported` adapter. It covers: + +- single-install configuration, and multi-tenant installs via an + application-implemented `InstallStore` + ([ADR 0006](adr/0006-multi-tenant-install.md)) +- signing secret verification and URL verification +- bot identity discovery during adapter initialization +- supported-shape decoding with unknown-field tolerance +- message-created and direct-message normalization, root-message thread + rooting, and self-message filtering +- retry metadata observation +- thread replies in plain text and portable Markdown (via Slack's + `markdown_text` field) +- native ephemeral messages with explicit DM fallback +- slash commands as Command Events and `block_actions` as Interaction Events + ([ADR 0003](adr/0003-slash-commands.md), + [ADR 0004](adr/0004-interactive-components.md)) +- native Block Kit posting, modal open, and `response_url` responses via + typed adapter access +- Web API rate-limit retry with `Retry-After` handling, bounded backoff, and + a typed `RateLimited` error ([ADR 0005](adr/0005-rate-limit-handling.md)) +- thread history read-through via the `HistoryReader` Optional Capability + ([ADR 0009](adr/0009-message-history.md)) + +The adapter uses local structs for the Slack payload shapes it supports, +preserves raw payload data as an escape hatch, and validates required fields +for supported event types. It is not a complete Slack product surface; see +[Intentional Gaps](explanation.md#intentional-gaps). + +### Linear Adapter + +The Linear adapter (`adapters/linear`) is `experimental`: the implementation +is broad and hardened, but the upstream Linear agent API is itself in +developer preview, so no production promises are made yet. It covers: + +- single-install app-actor client credentials with granted-scope + verification, and multi-tenant installs via an application-implemented + `InstallStore` with per-install webhook secrets and credentials or + pre-exchanged access tokens ([ADR 0006](adr/0006-multi-tenant-install.md)) +- webhook signing secret verification and timestamp replay checks +- app actor and organization identity discovery during adapter + initialization +- Linear `AgentSessionEvent` created and prompted normalization, including + assignment/delegation-created sessions emitted by Linear +- generic issue/comment participation outside agent sessions, with a + thread-kind discriminator in the opaque thread ID + ([ADR 0013](adr/0013-linear-generic-comments.md)) +- source-comment-based event identity for dedupe, tenant-correct opaque + thread IDs, and thread handle reconstruction for stored thread IDs +- self-message filtering through the discovered app actor identity +- the full agent activity surface through typed adapter access: thoughts, + responses, actions, elicitations, errors, and session updates with plans + and external URLs ([ADR 0008](adr/0008-linear-full-adapter.md)) +- thread history read-through via the `HistoryReader` Optional Capability, + reading agent-session activities and issue-comment threads + ([ADR 0009](adr/0009-message-history.md)) +- GraphQL rate-limit retry with a typed `RateLimited` error + ([ADR 0005](adr/0005-rate-limit-handling.md)) +- a `GraphQL` escape hatch and a `RawMessage` escape hatch (including the + user-initiated stop signal) +- plain text and portable Markdown pass-through for Linear activity bodies + +The Linear adapter follows the Slack adapter pattern: supported payload +shapes are modeled locally, low-level HTTP and GraphQL calls stay private, +and platform-specific behavior is exposed through narrow methods rather than +a raw Linear client. The [Linear agent sessions guide](how-to/linear-agent-sessions.md) +walks through building an agent; the tracked list of Linear agent APIs not +yet wrapped is in [linear-agent-capabilities.md](linear-agent-capabilities.md). ## Examples -Runnable, documented examples live in [`examples/`](../examples/): +Runnable, documented examples live in [`examples/`](../examples/). Pick one: -- [`slack-hello-world`](../examples/slack-hello-world/README.md) — memory - state, no infrastructure (the [tutorial](tutorials/slack-bot.md) target). +- [`slack-hello-world`](../examples/slack-hello-world/README.md) — start here. + Memory state, no infrastructure; the [tutorial](tutorials/slack-bot.md) + walks through it end to end. - [`slack-redis-state`](../examples/slack-redis-state/README.md), [`slack-postgres-state`](../examples/slack-postgres-state/README.md), [`slack-nats-state`](../examples/slack-nats-state/README.md) — the same bot - on each durable state backend, each with a `compose.yaml`. + on each durable state backend. Each lives in its own module (so the core + module does not pull backend dependencies) and ships a `compose.yaml`, a + `pitchfork.toml`, and a README with the backend URL, service startup, and + Slack setup steps. - [`linear-agent-hello-world`](../examples/linear-agent-hello-world/README.md) — - Linear agent sessions with memory state. + Linear agent sessions with memory state. Requires a Linear OAuth app + installed as an app actor and a public HTTPS webhook URL. + +The memory-backed examples run without local infrastructure: + +```sh +go run ./examples/slack-hello-world +go run ./examples/linear-agent-hello-world +``` + +The state-backed examples start their backend with Docker Compose (or let +Pitchfork supervise it from the example's `pitchfork.toml`): + +```sh +cd examples/slack-redis-state +docker compose up -d redis # or: pitchfork start redis +go run . +``` + +## Testing Contract + +Tests verify external behavior and public contracts, not private +implementation details. Required test families: + +- runtime construction and shutdown +- handler registration and replacement +- routing order and no-op missing handlers +- explicit subscription and unsubscribe +- direct-message implicit mention routing +- self-message filtering +- accepted, ignored, rejected, duplicate, and lock-conflict events +- state conformance across memory, Redis, Postgres, and NATS +- token-owned lock lease acquire, release, extend, expiry, and stale release +- Slack signature verification and URL verification +- Slack golden payload normalization +- thread ID construction and validation +- thread handle reconstruction +- text, markdown, sent message, ephemeral, and ephemeral fallback posting +- typed adapter access +- documentation coverage for intentional Vercel differences (README, this + reference, the explanation page, and GoDoc) + +Local test commands: + +```sh +mise run test +mise run test:root +mise run test:adapters +mise run test:examples +mise run test:nats +mise run test:postgres +mise run test:redis +``` + +`mise run test` is a composite task that runs the root module tests, +`test:adapters`, and `test:examples`. The adapter-focused task also exercises +the NATS, Redis, and Postgres state modules. The Redis and Postgres state +tests use Testcontainers for real backend coverage and skip when Docker is +unavailable; the NATS tests run against an embedded JetStream server. diff --git a/documentation_test.go b/documentation_test.go index c2f0e07..92ba36f 100644 --- a/documentation_test.go +++ b/documentation_test.go @@ -2,32 +2,60 @@ package chat_test import ( "os" + "os/exec" + "path/filepath" "regexp" "strings" "testing" ) +// TestDocumentationCoversIntentionalVercelDifferences pins each documented +// Vercel Chat SDK divergence to its Diátaxis home: the README landing page +// states the relationship, docs/reference.md carries the runtime semantics, +// and docs/explanation.md carries the intentional gaps. func TestDocumentationCoversIntentionalVercelDifferences(t *testing.T) { t.Parallel() - readme, err := os.ReadFile("README.md") - if err != nil { - t.Fatalf("read README.md: %v", err) + cases := []struct { + path string + phrases []string + }{ + { + path: "README.md", + phrases: []string{ + "not a TypeScript API port", + }, + }, + { + path: "docs/reference.md", + phrases: []string{ + "Handlers are single-slot per hook", + "Message history is application-owned", + "Thread Application State", + "HistoryReader", + }, + }, + { + path: "docs/explanation.md", + phrases: []string{ + "not a TypeScript API port", + "no dedicated `OnDirectMessage` hook", + "no public proactive `OpenDM`", + "no thread application state APIs", + "no full Vercel Chat SDK feature parity", + }, + }, } - readmeText := string(readme) - for _, phrase := range []string{ - "not a TypeScript API port", - "Handlers are single-slot per hook", - "no dedicated `OnDirectMessage` hook", - "no public proactive `OpenDM`", - "no thread application state APIs", - "no full Vercel Chat SDK feature parity", - "Message history is application-owned", - "Thread Application State", - "HistoryReader", - } { - if !strings.Contains(readmeText, phrase) { - t.Fatalf("README.md does not mention %q", phrase) + for _, tc := range cases { + source, err := os.ReadFile(tc.path) + if err != nil { + t.Fatalf("read %s: %v", tc.path, err) + } + text := string(source) + for _, phrase := range tc.phrases { + if !strings.Contains(text, phrase) { + t.Fatalf("%s does not mention %q", tc.path, phrase) + } } } @@ -99,7 +127,7 @@ func TestDocumentationCoversMessageHistoryCapability(t *testing.T) { phrases []string }{ { - path: "README.md", + path: "docs/reference.md", phrases: []string{ "HistoryReader", "Optional Capability", @@ -185,3 +213,48 @@ func TestLinearHowToSnippetsAreExtractedFromBuildableSource(t *testing.T) { } } } + +// TestREADMEMarkedSnippetsBuild keeps the README's hello-world honest: every +// fenced Go block annotated with a `` marker must be a complete +// program that compiles against the current module. +// +// Deliberately not parallel: the `go build` subprocess runs before the parallel +// batch so its CPU burst cannot skew timing-sensitive dispatch tests. +func TestREADMEMarkedSnippetsBuild(t *testing.T) { + readme, err := os.ReadFile("README.md") + if err != nil { + t.Fatalf("read README.md: %v", err) + } + pattern := regexp.MustCompile("(?s)\\s*```go\\n(.*?)```") + matches := pattern.FindAllStringSubmatch(string(readme), -1) + if len(matches) == 0 { + t.Fatal("README.md has no build-marked Go snippets") + } + goTool, err := exec.LookPath("go") + if err != nil { + t.Fatalf("locate go tool: %v", err) + } + for i, match := range matches { + snippet := match[1] + if !strings.HasPrefix(snippet, "package main\n") { + t.Fatalf("README snippet %d is not a main package", i+1) + } + // The package must live inside the root module so imports of + // github.com/coder/chat resolve; the leading underscore keeps a stray + // directory out of every `./...` pattern. + dir, err := os.MkdirTemp(".", "_readme_snippet_") + if err != nil { + t.Fatalf("create snippet dir: %v", err) + } + t.Cleanup(func() { _ = os.RemoveAll(dir) }) + if err := os.WriteFile(filepath.Join(dir, "main.go"), []byte(snippet), 0o600); err != nil { + t.Fatalf("write snippet: %v", err) + } + // Inherit GOFLAGS (for example -race) so the build shares this test + // run's cache instead of recompiling every dependency. + cmd := exec.Command(goTool, "build", "-o", os.DevNull, "./"+dir) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("README snippet %d does not build: %v\n%s", i+1, err, out) + } + } +}