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.
- 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 withcomposer hooks. - Run
composer test:e2ewhen you touch the frontend or the chat/stream surface. It's excluded fromcomposer checkand from CI (needs Playwright + built assets); it is a required gate before a release. - Rebuild
dist/(npm run build) wheneverresources/jschanges — it's committed, and the hook checks it. - Never
git commitwithout being asked. - PRD.md / GOAL.md are the spec. If code and spec disagree, stop and reconcile — don't invent behavior.
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 scaffold is complete and green (Pest, Pint, PHPStan); feature work builds on top of it.
src/ # PHP — the package
├── SynapseServiceProvider.php # register/boot: routes, views, publish, commands, schedule
├── SynapseApplicationServiceProvider.php # base for the published gate provider
├── 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 # /api group (stubbed) + 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, published on install
workbench/ # Testbench dev app + 4 sample 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: PRD.md, GOAL.md, SCAFFOLDING.md, DESIGN_FEEDBACK.md.
- Correctness — does it match the PRD/GOAL for this feature?
- Simplicity — is this the simplest solution that works?
- Readability — can human understand it in 30 seconds?
- 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
ConversationRepositorycascade earned extraction because prune, clear, and the future 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;
SynapseModelis the deliberate exception for shared framework wiring.
- 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-ignorewithout 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/// FIXMEwithout 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 (PRD Feature 6).
- 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
mixedwhere 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 ofagent_conversations.
PHP
- Laravel style, enforced by Pint (
laravelpreset) — runcomposer lintbefore committing. Don't introducedeclare(strict_types=1)(not used here); match the surrounding file. - Type everything: params, returns, properties; constructor property promotion;
@propertydocblocks 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. The only place Radix is imported.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.- Details and the element inventory: plans/FRONTEND.md.
- React + TypeScript (strict), Tailwind v4 utilities, shadcn/ui vendored into
resources/js/elements(components.jsonuialias) and restyled to our design tokens. 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 inresources/js/styles/app.css(dark-first). Never hard-code colors. - use Icons from
lucide-react— no inline SVG. - Named exports; keep the bundle lean.
| 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.
- One behavior per
it(). If the name needs "and", split it — a failure should name exactly what regressed. Share expensive setup withbeforeEach/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 testgreen 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.
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.
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@nameto[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-descriptionexists 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-stringclick()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/assertMissingoverassertSee/assertDontSee. type()resolves fields byname/id, not by label. Pass a testid —type('@composer-input', '…'). A field with only anaria-labelhangs until timeout.- The harness captures a streamed body in its own output buffer (
ob_start()→sendContent()→ob_get_clean()), so anything that callsob_flush()sends its bytes to stdout and hands the browser nothing. Guard the flush onPHP_SAPI, never onheaders_sent()— seeStreamEmitter::shouldFlush()andtests/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
pendingtool card is alreadysuccessby 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-urlencodedbodies and passes an empty files array toRequest::create()(a@TODOin 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. - 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.
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-agentNever 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.
- Never touch the SDK's tables. Synapse is a dev tool, not a production logger.
- Mirror the agent, don't fake it. Conversational agents get full history; stateless agents get one message per turn (PRD Feature 2).
- One catch-all owns errors. Any
\Throwableduring invocation (including a throwing toolhandle()) becomes an inline error card (PRD Feature 6). config()at runtime,env()only in config files. Keepsconfig:cachesafe.- Store replay data in the SDK's own serialization shapes (
ToolCall::toArray(),Usage::toArray(),File::fromArray()); Synapse-specific observations live insynapse_tool_invocations. - Cascade deletes in
ConversationRepository, never DB foreign keys. dist/is committed;references/andtesting-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 |
- Think before coding. State assumptions; if multiple interpretations exist, surface them rather than picking silently; if something's unclear, ask.
- Simplicity first. Minimum code that solves the problem — nothing speculative. If 200 lines could be 50, rewrite.
- 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).
- Goal-driven. Turn tasks into verifiable goals: "fix the bug" → write a failing test that reproduces it, then make it pass.
- 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.
This repo deliberately keeps its docs synchronized. When your change affects one, update it:
| Change | Update |
|---|---|
| A technical decision / mechanism | PRD.md |
| User-visible behavior | GOAL.md (and PRD if the mechanism changed) |
| 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 |
PRD and GOAL move together — a behavior change usually touches both.
| 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 product spec | PRD.md / GOAL.md |
| Check dev commands | DEV.md |
- Matches the PRD/GOAL intent for the feature.
- Covered by Pest tests;
composer checkgreen (lint + analyse + test). composer test:e2egreen if the frontend or chat/stream surface changed.dist/rebuilt ifresources/jschanged.- PRD/GOAL updated if behavior or a technical decision changed.