Guidance for any agent or contributor writing code in this repository. Read this first, then DEV.md for exact commands. For how Laravel packages work, invoke the laravel-package skill. For what we're building, GOAL.md (user-facing) and PRD.md (technical) are the source of truth.
php artisan boost:add-skill redberry/synapse
Save this content to: AGENTS.md
# Working on Synapse
Guidance for any agent or contributor writing code in this repository. Read this first, then [DEV.md](DEV.md) for exact commands. For *how Laravel packages work*, invoke the **`laravel-package`** skill. Use the current user's request and the active GitHub issue's goal and acceptance criteria for scope. [README.md](README.md) and [UPGRADE.md](UPGRADE.md) describe supported behavior; inspect implementation and tests for technical details.
## Important notes
- **Plan before coding.** For a non-trivial task, state a short plan (steps → how you'll verify each) and work through it.
- **Run the gates before reporting done:** `composer check` (lint + analyse + test). All green, every time. A pre-commit hook enforces this — enable it once with `composer hooks`.
- **Run `composer test:e2e`** when you touch the frontend or the chat/stream surface. It's excluded from `composer check` and from CI (needs Playwright + built assets); it is a required gate before a release.
- **Sequence feature and browser runs within a checkout.** Installation tests publish/remove Testbench's temporary migrations; simultaneous suites can race. Use separate copies for concurrent compatibility checks.
- **Rebuild `dist/`** (`npm run build`) whenever `resources/js` changes — it's committed, and the hook checks it.
- **Never `git commit` without being asked.**
- **Resolve scope conflicts explicitly.** If the requested acceptance criteria, maintained docs, and implementation disagree, identify the discrepancy before changing behavior.
## What this is
**Synapse** (`redberry/synapse`, namespace `Redberry\Synapse\`) is a Laravel package: a development dashboard for AI agents built with the Laravel AI SDK (`laravel/ai`). A React SPA served by a Laravel service provider — think Telescope/Horizon, but for AI agents. The package ships discovery, chat, recording, approvals, sources, completion explanations, and model-step inspection, with Pest, Pint, and PHPStan gates.
## Repository map
```
src/ # PHP — the package
├── SynapseServiceProvider.php # register/boot: routes, views, publish, commands, schedule
├── SynapseApplicationServiceProvider.php # compatibility base; published provider is self-contained
├── Chat/ # invocation, approvals, recording, replay, SSE projection
├── Discovery/ # agent metadata, tools, models, diagnostics
├── Synapse.php # static helpers: asset tags, script vars, auth callback
├── Http/{Controllers,Middleware}/ # HomeController (SPA shell), Authorize (viewSynapse gate)
├── Console/ # Install / Prune / Clear commands
├── Migrations/SynapseMigration.php # legacy migration base retained for compatibility
├── Models/ # SynapseModel base + Conversation/Message/ToolInvocation
└── Repositories/ # ConversationRepository (cascade delete lives here)
config/synapse.php # publishable config (enabled, ui, discovery, playground, storage, retention)
database/migrations/ # three self-contained synapse_* tables (publish-only)
routes/web.php # discovery/chat/approval/history/attachment APIs + SPA catch-all
resources/js/ # React + TS SPA — elements/ → components/ → composed/ → pages/
# (+ hooks, lib, types, styles)
resources/views/layout.blade.php # SPA shell + window.Synapse bootstrap
stubs/ # SynapseServiceProvider.stub (published viewSynapse gate)
dist/ # compiled assets — COMMITTED, inlined at request time
workbench/ # Testbench dev app + sample/diagnostic agents
tests/ # Pest + Testbench (Unit/, Feature/, Browser/ e2e)
.githooks/ # pre-commit hook (enable with `composer hooks`)
testing-laravel-project/ # gitignored real app for manual install testing (bin/setup-testing-app.sh)
references/ # gitignored: laravel/ai, telescope, horizon (read-only reference)
```
Docs: [README.md](README.md), [UPGRADE.md](UPGRADE.md), [DEV.md](DEV.md), [release checklist](docs/RELEASE.md). Completed implementation history lives in closed GitHub issues, merged PRs, and Git history. Keep only unfinished work in `plans/`.
## Development principles
### Hierarchy of priorities
1. **Correctness** — does it meet the agreed goal and acceptance criteria?
2. **Simplicity** — is this the simplest solution that works?
3. **Readability** — can human understand it in 30 seconds?
### KISS · DRY · SOLID — applied pragmatically
- **KISS** — don't over-engineer. Three similar lines beat a premature abstraction. No config options, flexibility, or helper indirection nobody asked for.
- **DRY** — extract shared logic only once it appears in 2+ places with the same shape. Premature deduplication couples worse than duplication. (The `ConversationRepository` cascade earned extraction because prune, clear, and the conversation delete endpoint all need it.)
- **SOLID** — at package scale, the parts that matter: **single responsibility** (a controller doesn't build SQL; a command delegates to a repository/service), and **dependency inversion** (services/repositories receive collaborators via constructor injection so they're testable). Prefer composition over inheritance; `SynapseModel` is the deliberate exception for shared framework wiring.
## What NOT to do
- **Don't write comments that restate the code.** Use intention-revealing names. Add docblocks only where they carry type information PHPStan needs (array shapes, generics, model `@property`) or explain a non-obvious *why*.
- **Don't silence PHPStan** with `@phpstan-ignore` without explicit permission from developer, baseline entries, `assert()`, inline `@var`, casts, or widened types. An "always true/false" error means an annotation is wrong.
- **Don't leave `// TODO`/`// FIXME`** without a concrete action.
- **Don't handle impossible errors** (e.g. re-validating data a FormRequest/route-model-binding already guaranteed). One catch-all owns agent-invocation failures.
- **Don't add a dependency** before checking whether the framework or an existing dependency already solves it. Justify new frontend deps by a feature that needs them.
- **Don't use `mixed`** where a concrete type is known; narrow it. Don't reach for facades in services/repositories where constructor injection reads clearer and tests better — facades are fine at framework edges (providers, commands, controllers).
- **Don't concatenate SQL.** Eloquent/query-builder bindings only.
- **Don't reformat or refactor unrelated code** while making a change (see Surgical changes).
- **Don't touch the SDK's tables.** `synapse_*` tables are fully independent of `agent_conversations`.
## Coding standards
**PHP**
- Laravel style, enforced by **Pint (`laravel` preset)** — run `composer lint` before committing. Don't introduce `declare(strict_types=1)` (not used here); match the surrounding file.
- Type everything: params, returns, properties; constructor property promotion; `@property` docblocks on models.
- **PHPStan level 5 (larastan) stays green.** Fix the root cause, always.
- Small, single-concern classes.
**TypeScript / React**
- **Four layers**, each importing only from the ones above it:
- `elements/` — generic UI primitives (button, input, dropdown, tooltip, badge…). Domain-agnostic: they know nothing about agents or conversations. If a primitive library is introduced, import it only at this layer.
- `components/` — single-purpose domain components (an agent card, a tool chip, a status badge). Presentational: data in via props, events out via callbacks. No fetching.
- `composed/` — larger units assembled from several components (app shell, grids, panels, threads). Still props-in/events-out; no route-level data loading.
- `pages/` — one per route. Owns data fetching, hooks, page state, and wiring; composes the layers above.
- Reuse the current element inventory in `resources/js/elements`. Do not create a composed component for a single-use grouping unless it improves clarity.
- React + TypeScript (strict), Tailwind v4 utilities, and local primitives in `resources/js/elements` (`components.json` points its `ui` alias there). Native browser controls and existing primitives are preferred; no Radix or Vercel AI client dependency is currently installed. Import via the `@/` alias.
- Logic belongs in pages and hooks, not components. A component that needs data takes it as props.
- Theme via `--color-*` CSS tokens in `resources/js/styles/app.css` (dark-first). Never hard-code colors.
- use Icons from `lucide-react` — no inline SVG.
- Named exports; keep the bundle lean.
### Naming
| Thing | Convention | Example |
|-------|-----------|---------|
| Classes / enums | `StudlyCase` | `SynapseToolInvocation` |
| Methods / variables | `camelCase` | `deleteConversations()` |
| Constants | `SCREAMING_SNAKE` | `Synapse::VERSION` |
| Config keys | `snake_case` | `attachments_disk` |
| DB tables / columns | `snake_case` | `synapse_tool_invocations` |
| Routes / URLs | `kebab-case` | `/synapse/api/conversations` |
| Env vars | `SYNAPSE_` + `SCREAMING_SNAKE` | `SYNAPSE_DB_CONNECTION` |
| Publish tags | `synapse-` + `kebab` | `synapse-config` |
| React components | `PascalCase.tsx` | `AppShell.tsx` |
| Frontend lib modules | `camelCase.ts` | `api.ts`, `theme.ts` |
## Testing
Testbench + Pest. Run details in [DEV.md](DEV.md); philosophy here.
- **One behavior per `it()`.** If the name needs "and", split it — a failure should name exactly what regressed. Share expensive setup with `beforeEach`/`describe`, keep each test to a single assertion of intent.
- **Descriptive names:** `it('forbids access when the gate denies')`.
- **Test the real implementation.** Use the real DB (`RefreshDatabase`, in-memory sqlite). Avoid mocks; when you must fake I/O, use Laravel's fakes (`Storage::fake`, `Event::fake`, `Http::fake`, `Bus::fake`) over mocking libraries.
- **Feature-first.** Exercise real routes/commands/models over isolated units. Each test is independent.
- Every feature ships with tests; `composer test` green before done.
### Test tiers
| Tier | Location | Runs in | Purpose |
|------|----------|---------|---------|
| Unit | `tests/Unit/` | `composer test`, CI | Pure logic in isolation |
| Feature | `tests/Feature/` | `composer test`, CI | Routes, commands, models, repositories via the real app |
| Browser (e2e) | `tests/Browser/` | `composer test:e2e` only | Real browser (Pest 4 + Playwright): the compiled React app mounts, renders, streams, routes |
Browser tests are the **only** way to verify the PHP → SSE → React boundary, which is where Synapse's hardest bugs live. They are deliberately out of `composer test` and out of CI — they need Playwright and current `dist/` assets, so they're a manual gate before releases (and worth running whenever you change the frontend).
**Always drive browser tests with `Agent::fake()`** — never call a real provider: no API spend, no flakiness, deterministic streams.
### If the pre-commit hook fails but `composer test` passes
The hook runs `composer test --quiet`, which exports `SHELL_VERBOSITY=-1`, and
Pest emits **nothing** at that verbosity — a failure with no output. Get at it
with `SHELL_VERBOSITY=-1 ./vendor/bin/pest --testsuite=unit,feature --log-junit /tmp/junit.xml`.
The usual cause is a test asserting on console output, which Symfony suppresses
under the same variable. Pass `'-v' => true` in the command's parameters; an
explicit output buffer does not help, because only an input option outranks the
environment.
### Writing browser tests
**Target with `data-testid`; assert content as text.** These are different jobs and mixing them produces tests that pass while the UI is broken.
- **Targeting / scoping → `data-testid`.** Pest resolves `@name` to `[data-testid=name]`. Use it to find an element to click, to assert presence/absence, and to scope a content assertion.
- **Content → real text**, scoped to the element it belongs to: `assertSeeIn('@tool-detail', 'Search query text')`. Asserting only that `@tool-description` *exists* would pass with empty or garbled copy — exactly the bug a dashboard must never ship.
- **Interactive controls prefer their accessible name** over a testid — `click('[aria-label="Close info panel"]')` targets *and* enforces a11y. Add a testid only when there's no sensible accessible name. (Pest's plain-string `click()` matches visible text; an icon-only button has none, and it will hang until timeout rather than fail fast.)
**Add a testid only when a test needs it** — never pre-emptively. Sparse, deliberate ids stay meaningful; a testid on every `<div>` turns the suite into structure-testing.
Gotchas worth knowing:
- **Scoped assertions run in Playwright strict mode** — the text must match exactly one node inside the scope. `assertSeeIn('@info-panel', 'PROVIDER')` fails when the panel also contains "PROVIDER OPTIONS". Pick unambiguous strings.
- **Text assertions don't wait for a re-render; element assertions do.** After an interaction, prefer `assertPresent` / `assertMissing` over `assertSee` / `assertDontSee`.
- **`type()` resolves fields by `name`/`id`, not by label.** Pass a testid — `type('@composer-input', '…')`. A field with only an `aria-label` hangs until timeout.
- **The harness captures a streamed body in its own output buffer** (`ob_start()` → `sendContent()` → `ob_get_clean()`), so anything that calls `ob_flush()` sends its bytes to stdout and hands the browser nothing. Guard the flush on `PHP_SAPI`, never on `headers_sent()` — see `StreamEmitter::shouldFlush()` and `tests/Feature/Chat/StreamFlushTest.php`.
- **No browser test can observe a mid-stream state.** That same buffer means every SSE part reaches the page in one chunk, however long the run takes — a `pending` tool card is already `success` by the time the DOM exists. Assert the *end* state in a browser test and the streaming itself against a real server (below).
- **The harness cannot upload a file.** It parses only `application/x-www-form-urlencoded` bodies and passes an empty files array to `Request::create()` (a `@TODO` in pest-plugin-browser). Browser tests can drive the composer as far as the chip; everything past it belongs in a feature test with a real multipart request.
- **All conversational agents share one fake gateway** (the decorator's), so calling `fakeAgent()` twice with different responses replaces the first. A test needing two agents to behave differently passes one closure keyed on the prompt.
- **SDK 1.3 fakes discard tool calls attached to a `TextResponse`.** For a response containing both narration and tool calls, use `StepSequenceAgent` middleware fixtures with public `StepResponse` objects. They exercise the SDK loop without provider I/O; keep `fakeAgent()` registered as the network guard.
- **Faked responses given as a list don't advance past a throwing entry.** The SDK's fake increments its cursor with `tap()` *after* marshalling, so an entry that throws is returned forever. Use a closure keyed on the prompt when a test needs a failure followed by a success.
### Verifying that it actually streams
Neither tier can: both run Laravel in-process on the CLI SAPI, where flushing is
deliberately off. Streaming is only real over HTTP, so check it against
`testing-laravel-project` — and check it after any change to `StreamEmitter` or
the controller's stream closure.
```bash
curl -sN -o /dev/null -X POST http://127.0.0.1:8000/synapse/api/chat/app.agents.slow-tool-agent/send -b cookies.txt -H "X-XSRF-TOKEN: $TOKEN" -H 'Content-Type: application/json' -d '{"message":"Query the analytics service with seconds=4."}' -w 'ttfb=%{time_starttransfer}s total=%{time_total}s\n'
```
**`ttfb` must be a few milliseconds.** If it equals `total`, nothing is
streaming: the whole conversation is buffered and painted at once, which looks
like a hang and then a jump. That is precisely the regression
`StreamFlushTest` exists to prevent.
`bin/check-streaming.sh` wraps this — it fetches its own CSRF token, measures,
and exits non-zero when TTFB and total converge:
```bash
bin/check-streaming.sh http://127.0.0.1:8000 app.agents.slow-tool-agent
```
**Never pipe curl into another process to inspect a stream.** A pipe
block-buffers and makes a healthy stream look batched — this produced two false
diagnoses while the original bug was being found. Use `-w` with the body
discarded, or `os.read()` on the raw fd.
`SlowToolAgent` / `SlowTool` are the fixtures for this. Every other tool returns
in microseconds, so its `tool-input-available` and `tool-output-available` land
in the same TCP segment and the card renders already green — the `pending` state
is real but unobservable. `SlowTool` sleeps for a requested 0–5 seconds, which
holds the amber card open long enough to watch in the playground.
### What to test
| Layer | What to test |
|-------|--------------|
| Config | defaults, env resolution, the production route-guard |
| Migrations / models | tables exist, casts, uuid7 keys, relationships |
| Repositories | cascade deletion, prune windows |
| Commands | behavior, output, side effects |
| Controllers / API | status codes, response contract, auth gate |
| Discovery | finds agents, classifies tools, extracts metadata |
| Recorder | SDK event → `synapse_tool_invocations` row lifecycle |
**Don't test:** framework internals, Eloquent itself, `laravel/ai` behavior, trivial accessors.
## Synapse golden rules
1. **Never touch the SDK's tables.** Synapse is a dev tool, not a production logger.
2. **Mirror the agent, don't fake it.** Conversational agents get full history; stateless agents get one message per turn.
3. **One catch-all owns errors.** Any `\Throwable` during invocation (including a throwing tool `handle()`) becomes an inline error card.
4. **`config()` at runtime, `env()` only in config files.** Keeps `config:cache` safe.
5. **Store replay data in the SDK's own serialization shapes** (`Step::toArray()`, `TextUsage::toArray()`, SDK file serialization). Private `steps` and ordered display `parts` have distinct roles; tool observations live in `synapse_tool_invocations`. See `MessageHistory`, `ConversationPresenter`, and the recording tests for the replay/display contract. All agents are wrapped; the original capabilities determine whether history is supplied. Failover capture retains completed results before the next provider attempt replaces its context.
6. **Cascade deletes in `ConversationRepository`**, never DB foreign keys.
7. **`dist/` is committed; `references/` and `testing-laravel-project/` are gitignored.**
## Learning from references/
`references/` holds read-only copies (gitignored) to learn patterns from — **study, don't copy verbatim**:
| Pattern | Where |
|---------|-------|
| Install command flow (publish tags + register provider) | `references/telescope/src/Console/InstallCommand.php` |
| App service provider + `view*` gate | `references/telescope/src/TelescopeApplicationServiceProvider.php` |
| SPA serving (blade shell, catch-all, asset helpers) | `references/horizon/` (`HomeController`, `Horizon.php`, `routes/web.php`) |
| Migration connection base | `references/laravel/ai/src/Migrations/AiMigration.php` |
| SDK events / contracts / streaming events | `references/laravel/ai/src/{Events,Contracts,Streaming}` |
| Conversation store round-trip (message rehydration) | `references/laravel/ai/src/Storage/DatabaseConversationStore.php` |
## Writing-code principles
1. **Think before coding.** State assumptions; if multiple interpretations exist, surface them rather than picking silently; if something's unclear, ask.
2. **Simplicity first.** Minimum code that solves the problem — nothing speculative. If 200 lines could be 50, rewrite.
3. **Surgical changes.** Every changed line should trace to the request. Don't "improve" adjacent code, comments, or formatting. Match existing style. Remove orphans *your* change created; leave pre-existing dead code (mention it).
4. **Goal-driven.** Turn tasks into verifiable goals: "fix the bug" → write a failing test that reproduces it, then make it pass.
5. **Reuse over duplication.** Before writing something new, check whether it already exists (a helper, a base class, an SDK method). If it needs refactoring to reuse, ask first and explain what's needed.
## Keeping docs in sync
This repo deliberately keeps its docs synchronized. When your change affects one, update it:
| Change | Update |
|--------|--------|
| A technical decision / mechanism | Explain it in the PR; update **AGENTS.md** only for lasting contributor guidance |
| User-visible behavior | **README.md**, plus **UPGRADE.md** when migration steps change |
| Coding conventions / patterns / standards | **AGENTS.md** (this file) |
| Dev commands / workflow | **DEV.md** |
| Laravel-package methodology | the `laravel-package` skill |
| Quick-start / prerequisites | **README.md** |
Avoid duplicating implementation details across documents. Feature plans belong to unfinished work; remove completed plans and their unused assets when their issue/PR records provide the history.
## Quick reference: task → location
| You need to… | Go to… |
|--------------|--------|
| Add an API endpoint | `routes/web.php` (`/api` group, before the catch-all) + `src/Http/Controllers` |
| Add business logic | a service in `src/` (e.g. `Discovery/`, `Recording/`) |
| Add a DB table | `database/migrations/` (extend Laravel's `Migration` and keep the file independent of Synapse classes) |
| Add a model | `src/Models/` (extend `SynapseModel`) |
| Add a command | `src/Console/` + register in `SynapseServiceProvider` |
| Add config | `config/synapse.php` |
| Add a React page | `resources/js/pages/` + `router.tsx` |
| Add a UI primitive | `resources/js/elements/` (shadcn `ui` alias points here) |
| Add a domain component | `resources/js/components/` |
| Assemble several components | `resources/js/composed/` |
| Add a sample agent | `workbench/app/Agents/` |
| Add a test | `tests/Feature/` (or `tests/Unit/`) |
| Add a browser e2e test | `tests/Browser/` — run with `composer test:e2e` |
| Check feature scope | Current user request + active GitHub issue acceptance criteria |
| Check dev commands | `DEV.md` |
## Definition of done
- Meets the agreed goal and acceptance criteria.
- Covered by Pest tests; `composer check` green (lint + analyse + test).
- `composer test:e2e` green if the frontend or chat/stream surface changed.
- `dist/` rebuilt if `resources/js` changed.
- Maintained docs updated where behavior, upgrade steps, or contributor guidance changed.
Guidance for any agent or contributor writing code in this repository. Read this first, then DEV.md for exact commands. For how Laravel packages work, invoke the laravel-package skill. Use the current user's request and the active GitHub issue's goal and acceptance criteria for scope. README.md and UPGRADE.md describe supported behavior; inspect implementation and tests for technical details.
composer check (lint + analyse + test). All green, every time. A pre-commit hook enforces this — enable it once with composer hooks.composer test:e2e when you touch the frontend or the chat/stream surface. It's excluded from composer check and from CI (needs Playwright + built assets); it is a required gate before a release.dist/ (npm run build) whenever resources/js changes — it's committed, and the hook checks it.git commit without being asked.Synapse (redberry/synapse, namespace Redberry\Synapse\) is a Laravel package: a development dashboard for AI agents built with the Laravel AI SDK (laravel/ai). A React SPA served by a Laravel service provider — think Telescope/Horizon, but for AI agents. The package ships discovery, chat, recording, approvals, sources, completion explanations, and model-step inspection, with Pest, Pint, and PHPStan gates.
src/ # PHP — the package
├── SynapseServiceProvider.php # register/boot: routes, views, publish, commands, schedule
├── SynapseApplicationServiceProvider.php # compatibility base; published provider is self-contained
├── Chat/ # invocation, approvals, recording, replay, SSE projection
├── Discovery/ # agent metadata, tools, models, diagnostics
├── Synapse.php # static helpers: asset tags, script vars, auth callback
├── Http/{Controllers,Middleware}/ # HomeController (SPA shell), Authorize (viewSynapse gate)
├── Console/ # Install / Prune / Clear commands
├── Migrations/SynapseMigration.php # legacy migration base retained for compatibility
├── Models/ # SynapseModel base + Conversation/Message/ToolInvocation
└── Repositories/ # ConversationRepository (cascade delete lives here)
config/synapse.php # publishable config (enabled, ui, discovery, playground, storage, retention)
database/migrations/ # three self-contained synapse_* tables (publish-only)
routes/web.php # discovery/chat/approval/history/attachment APIs + SPA catch-all
resources/js/ # React + TS SPA — elements/ → components/ → composed/ → pages/
# (+ hooks, lib, types, styles)
resources/views/layout.blade.php # SPA shell + window.Synapse bootstrap
stubs/ # SynapseServiceProvider.stub (published viewSynapse gate)
dist/ # compiled assets — COMMITTED, inlined at request time
workbench/ # Testbench dev app + sample/diagnostic agents
tests/ # Pest + Testbench (Unit/, Feature/, Browser/ e2e)
.githooks/ # pre-commit hook (enable with `composer hooks`)
testing-laravel-project/ # gitignored real app for manual install testing (bin/setup-testing-app.sh)
references/ # gitignored: laravel/ai, telescope, horizon (read-only reference)
Docs: README.md, UPGRADE.md, DEV.md, release checklist. Completed implementation history lives in closed GitHub issues, merged PRs, and Git history. Keep only unfinished work in plans/.
ConversationRepository cascade earned extraction because prune, clear, and the conversation delete endpoint all need it.)SynapseModel is the deliberate exception for shared framework wiring.@property) or explain a non-obvious why.@phpstan-ignore without explicit permission from developer, baseline entries, assert(), inline @var, casts, or widened types. An "always true/false" error means an annotation is wrong.// TODO/// FIXME without a concrete action.mixed where a concrete type is known; narrow it. Don't reach for facades in services/repositories where constructor injection reads clearer and tests better — facades are fine at framework edges (providers, commands, controllers).synapse_* tables are fully independent of agent_conversations.PHP
laravel preset) — run composer lint before committing. Don't introduce declare(strict_types=1) (not used here); match the surrounding file.@property docblocks on models.TypeScript / React
elements/ — generic UI primitives (button, input, dropdown, tooltip, badge…). Domain-agnostic: they know nothing about agents or conversations. If a primitive library is introduced, import it only at this layer.components/ — single-purpose domain components (an agent card, a tool chip, a status badge). Presentational: data in via props, events out via callbacks. No fetching.composed/ — larger units assembled from several components (app shell, grids, panels, threads). Still props-in/events-out; no route-level data loading.pages/ — one per route. Owns data fetching, hooks, page state, and wiring; composes the layers above.resources/js/elements. Do not create a composed component for a single-use grouping unless it improves clarity.resources/js/elements (components.json points its ui alias there). Native browser controls and existing primitives are preferred; no Radix or Vercel AI client dependency is currently installed. Import via the @/ alias.--color-* CSS tokens in resources/js/styles/app.css (dark-first). Never hard-code colors.lucide-react — no inline SVG.| Thing | Convention | Example |
|---|---|---|
| Classes / enums | StudlyCase |
SynapseToolInvocation |
| Methods / variables | camelCase |
deleteConversations() |
| Constants | SCREAMING_SNAKE |
Synapse::VERSION |
| Config keys | snake_case |
attachments_disk |
| DB tables / columns | snake_case |
synapse_tool_invocations |
| Routes / URLs | kebab-case |
/synapse/api/conversations |
| Env vars | SYNAPSE_ + SCREAMING_SNAKE |
SYNAPSE_DB_CONNECTION |
| Publish tags | synapse- + kebab |
synapse-config |
| React components | PascalCase.tsx |
AppShell.tsx |
| Frontend lib modules | camelCase.ts |
api.ts, theme.ts |
Testbench + Pest. Run details in DEV.md; philosophy here.
it(). If the name needs "and", split it — a failure should name exactly what regressed. Share expensive setup with beforeEach/describe, keep each test to a single assertion of intent.it('forbids access when the gate denies').RefreshDatabase, in-memory sqlite). Avoid mocks; when you must fake I/O, use Laravel's fakes (Storage::fake, Event::fake, Http::fake, Bus::fake) over mocking libraries.composer test green before done.| Tier | Location | Runs in | Purpose |
|---|---|---|---|
| Unit | tests/Unit/ |
composer test, CI |
Pure logic in isolation |
| Feature | tests/Feature/ |
composer test, CI |
Routes, commands, models, repositories via the real app |
| Browser (e2e) | tests/Browser/ |
composer test:e2e only |
Real browser (Pest 4 + Playwright): the compiled React app mounts, renders, streams, routes |
Browser tests are the only way to verify the PHP → SSE → React boundary, which is where Synapse's hardest bugs live. They are deliberately out of composer test and out of CI — they need Playwright and current dist/ assets, so they're a manual gate before releases (and worth running whenever you change the frontend).
Always drive browser tests with Agent::fake() — never call a real provider: no API spend, no flakiness, deterministic streams.
composer test passesThe hook runs composer test --quiet, which exports SHELL_VERBOSITY=-1, and
Pest emits nothing at that verbosity — a failure with no output. Get at it
with SHELL_VERBOSITY=-1 ./vendor/bin/pest --testsuite=unit,feature --log-junit /tmp/junit.xml.
The usual cause is a test asserting on console output, which Symfony suppresses
under the same variable. Pass '-v' => true in the command's parameters; an
explicit output buffer does not help, because only an input option outranks the
environment.
Target with data-testid; assert content as text. These are different jobs and mixing them produces tests that pass while the UI is broken.
data-testid. Pest resolves @name to [data-testid=name]. Use it to find an element to click, to assert presence/absence, and to scope a content assertion.assertSeeIn('@tool-detail', 'Search query text'). Asserting only that @tool-description exists would pass with empty or garbled copy — exactly the bug a dashboard must never ship.click('[aria-label="Close info panel"]') targets and enforces a11y. Add a testid only when there's no sensible accessible name. (Pest's plain-string click() matches visible text; an icon-only button has none, and it will hang until timeout rather than fail fast.)Add a testid only when a test needs it — never pre-emptively. Sparse, deliberate ids stay meaningful; a testid on every <div> turns the suite into structure-testing.
Gotchas worth knowing:
assertSeeIn('@info-panel', 'PROVIDER') fails when the panel also contains "PROVIDER OPTIONS". Pick unambiguous strings.assertPresent / assertMissing over assertSee / assertDontSee.type() resolves fields by name/id, not by label. Pass a testid — type('@composer-input', '…'). A field with only an aria-label hangs until timeout.ob_start() → sendContent() → ob_get_clean()), so anything that calls ob_flush() sends its bytes to stdout and hands the browser nothing. Guard the flush on PHP_SAPI, never on headers_sent() — see StreamEmitter::shouldFlush() and tests/Feature/Chat/StreamFlushTest.php.pending tool card is already success by the time the DOM exists. Assert the end state in a browser test and the streaming itself against a real server (below).application/x-www-form-urlencoded bodies and passes an empty files array to Request::create() (a @TODO in pest-plugin-browser). Browser tests can drive the composer as far as the chip; everything past it belongs in a feature test with a real multipart request.fakeAgent() twice with different responses replaces the first. A test needing two agents to behave differently passes one closure keyed on the prompt.TextResponse. For a response containing both narration and tool calls, use StepSequenceAgent middleware fixtures with public StepResponse objects. They exercise the SDK loop without provider I/O; keep fakeAgent() registered as the network guard.tap() after marshalling, so an entry that throws is returned forever. Use a closure keyed on the prompt when a test needs a failure followed by a success.Neither tier can: both run Laravel in-process on the CLI SAPI, where flushing is
deliberately off. Streaming is only real over HTTP, so check it against
testing-laravel-project — and check it after any change to StreamEmitter or
the controller's stream closure.
curl -sN -o /dev/null -X POST http://127.0.0.1:8000/synapse/api/chat/app.agents.slow-tool-agent/send -b cookies.txt -H "X-XSRF-TOKEN: $TOKEN" -H 'Content-Type: application/json' -d '{"message":"Query the analytics service with seconds=4."}' -w 'ttfb=%{time_starttransfer}s total=%{time_total}s\n'
ttfb must be a few milliseconds. If it equals total, nothing is
streaming: the whole conversation is buffered and painted at once, which looks
like a hang and then a jump. That is precisely the regression
StreamFlushTest exists to prevent.
bin/check-streaming.sh wraps this — it fetches its own CSRF token, measures,
and exits non-zero when TTFB and total converge:
bin/check-streaming.sh http://127.0.0.1:8000 app.agents.slow-tool-agent
Never pipe curl into another process to inspect a stream. A pipe
block-buffers and makes a healthy stream look batched — this produced two false
diagnoses while the original bug was being found. Use -w with the body
discarded, or os.read() on the raw fd.
SlowToolAgent / SlowTool are the fixtures for this. Every other tool returns
in microseconds, so its tool-input-available and tool-output-available land
in the same TCP segment and the card renders already green — the pending state
is real but unobservable. SlowTool sleeps for a requested 0–5 seconds, which
holds the amber card open long enough to watch in the playground.
| Layer | What to test |
|---|---|
| Config | defaults, env resolution, the production route-guard |
| Migrations / models | tables exist, casts, uuid7 keys, relationships |
| Repositories | cascade deletion, prune windows |
| Commands | behavior, output, side effects |
| Controllers / API | status codes, response contract, auth gate |
| Discovery | finds agents, classifies tools, extracts metadata |
| Recorder | SDK event → synapse_tool_invocations row lifecycle |
Don't test: framework internals, Eloquent itself, laravel/ai behavior, trivial accessors.
\Throwable during invocation (including a throwing tool handle()) becomes an inline error card.config() at runtime, env() only in config files. Keeps config:cache safe.Step::toArray(), TextUsage::toArray(), SDK file serialization). Private steps and ordered display parts have distinct roles; tool observations live in synapse_tool_invocations. See MessageHistory, ConversationPresenter, and the recording tests for the replay/display contract. All agents are wrapped; the original capabilities determine whether history is supplied. Failover capture retains completed results before the next provider attempt replaces its context.ConversationRepository, never DB foreign keys.dist/ is committed; references/ and testing-laravel-project/ are gitignored.references/ holds read-only copies (gitignored) to learn patterns from — study, don't copy verbatim:
| Pattern | Where |
|---|---|
| Install command flow (publish tags + register provider) | references/telescope/src/Console/InstallCommand.php |
App service provider + view* gate |
references/telescope/src/TelescopeApplicationServiceProvider.php |
| SPA serving (blade shell, catch-all, asset helpers) | references/horizon/ (HomeController, Horizon.php, routes/web.php) |
| Migration connection base | references/laravel/ai/src/Migrations/AiMigration.php |
| SDK events / contracts / streaming events | references/laravel/ai/src/{Events,Contracts,Streaming} |
| Conversation store round-trip (message rehydration) | references/laravel/ai/src/Storage/DatabaseConversationStore.php |
This repo deliberately keeps its docs synchronized. When your change affects one, update it:
| Change | Update |
|---|---|
| A technical decision / mechanism | Explain it in the PR; update AGENTS.md only for lasting contributor guidance |
| User-visible behavior | README.md, plus UPGRADE.md when migration steps change |
| Coding conventions / patterns / standards | AGENTS.md (this file) |
| Dev commands / workflow | DEV.md |
| Laravel-package methodology | the laravel-package skill |
| Quick-start / prerequisites | README.md |
Avoid duplicating implementation details across documents. Feature plans belong to unfinished work; remove completed plans and their unused assets when their issue/PR records provide the history.
| You need to… | Go to… |
|---|---|
| Add an API endpoint | routes/web.php (/api group, before the catch-all) + src/Http/Controllers |
| Add business logic | a service in src/ (e.g. Discovery/, Recording/) |
| Add a DB table | database/migrations/ (extend Laravel's Migration and keep the file independent of Synapse classes) |
| Add a model | src/Models/ (extend SynapseModel) |
| Add a command | src/Console/ + register in SynapseServiceProvider |
| Add config | config/synapse.php |
| Add a React page | resources/js/pages/ + router.tsx |
| Add a UI primitive | resources/js/elements/ (shadcn ui alias points here) |
| Add a domain component | resources/js/components/ |
| Assemble several components | resources/js/composed/ |
| Add a sample agent | workbench/app/Agents/ |
| Add a test | tests/Feature/ (or tests/Unit/) |
| Add a browser e2e test | tests/Browser/ — run with composer test:e2e |
| Check feature scope | Current user request + active GitHub issue acceptance criteria |
| Check dev commands | DEV.md |
composer check green (lint + analyse + test).composer test:e2e green if the frontend or chat/stream surface changed.dist/ rebuilt if resources/js changed.How can I help you explore Laravel packages today?