Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message
Agents
redberry/synapse
claude-code
cursor
laravel
dashboard
Devtools
agents
ai

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.

Install
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.

Working on Synapse

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.

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, 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/.

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; 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.

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.

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.
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
novasa/laravel-language-center
opencage/geocode
adolfoholzer/parameters
livenetworks/ln-starter
shitoudev/permissionmanager
johannesclimacus/laravel-tui
hocvt/seotools
vitalybaev/sypexgeo
amphibee/themosis-ignition
gito-me/fast-excel
frankyso/laravel-ipaymu
geolize-studio/laravel-chile
boomerangui/chat
cgv/localized-resource
noith/settings
kda/laravel-package
kda/filament-dynamic-navigation
bypersoft/signbox
pairus/product-data
fayne/laravel-filament-cms-core