Glassys sits in front of several coding agents — Claude Code, Codex, Cursor, OpenCode, and anything that speaks ACP — and each one gets an adapter whose only job is to turn that agent's own event stream into the handful of things the chat screen knows how to draw: text, a tool card with a path or a command, a diff, a shell with its output and its exit code, a run that ended.

Those mappers had tests. The tests passed. And several adapters still showed almost nothing of what their agent was actually doing.

The reason is embarrassing in hindsight. I had written the fixtures by hand, from how I assumed the events looked, and nothing ever compared them against the SDKs. A mapper and its test can agree perfectly about a shape that never arrives at runtime, and at that point the test isn't a test. It's a second copy of my assumption, with a green check next to it.

Recording instead of imagining

So I stopped writing fixtures. Every adapter can now dump the raw events it receives to a file — one environment variable, GLASSYS_RECORD_FIXTURES, pointed at a directory — so a fixture is a recording of a real session rather than a guess. Each mapper now follows the types that are actually installed, and the fixtures follow them too.

On top of that there is a smoke script that drives one adapter against a real agent in a throwaway workspace and checks that the whole chain arrives: text, a tool card with its path or command, a diff for the edit, a shell that fails, a run that finishes. It isn't in CI, because it needs credentials and spends tokens, but it's one command whenever I touch an adapter.

The first real recordings paid for the afternoon on their own. A Claude run with Claude Code signed out painted the sign-in error twice — once from the synthetic assistant message, once from the result — and added a usage row of all zeros. Both are gone, and the zero-usage case is fixed for every adapter. An OpenCode CLI that was installed but crashing got reported as "install the OpenCode CLI"; the probe now runs the version command and tells you the CLI is installed but does not run, with the exit code or signal.

The ACP bug no fixture could have caught

ACP is how Glassys supports agents I haven't written an adapter for, Gemini among them. My initialize went out with Content-Length framing. ACP is newline-delimited, and in ACP the agent never speaks first — so nothing came back, and every spec-compliant agent sat there until the setup timeout. Nothing in my own suite spoke the real wire format, so the bug just waited for someone to point a real agent at it.

With the framing fixed, the rest of that protocol got the same treatment. Permission answers pick the agent's own option ids by kind instead of matching on names, authentication only runs when the session actually asks for it, terminals honour the working directory, the environment and the output limit and get killed as a process group, a killed terminal stays readable instead of claiming it exited cleanly, file reads accept line ranges, unknown methods get a proper method-not-found error, and cancelling waits on the run's own signal rather than two polling loops.

A fake agent, so CI speaks the protocol

The other half of that fix is a fake ACP agent: spec-shaped, with no model behind it. Its demo turn drives writes through the host, a permission-gated terminal that fails, and a file read; ask it for a long answer and it streams until cancelled. That runs in CI now, so the protocol path is finally covered by something that costs no tokens and needs no credentials — diffs, shell output and exit codes, denials with auto-run off, cancelling, the model catalogue.

ACP also lists the models its agent announced when the session opened, kept from the last session or read once by a short probe that isn't retried for five minutes. It sends image blocks only to agents that said they take images, and gives the rest the paths in the text. And it has its own permission label at last: with auto-run off, Glassys refuses writes, terminals and any non-read permission request, which is worth saying plainly, because it used to show ACP users a label borrowed from Cursor that explained nothing.

What the other adapters had been hiding

Reading the real event types, I found a different kind of bug in each one.

  • Cursor puts tool results one level deeper than I had them, so diffs, output and a failing shell's exit code were simply never displayed. Shell output now streams into its card, and MCP calls are named by provider and tool.
  • OpenCode reports failures as an error result rather than throwing, so a missing session or a rejected prompt looked like success. Permission requests used to hang for ten minutes and are refused now. It was also running sessions in the gateway's own working directory instead of the workspace, which is a strange thing to find out about your own tool.
  • Claude: a cancelled turn is now read through to its own result. Returning earlier left that result sitting in the queue, so every later answer came one turn late. Denials are flagged, edits take their diffs from Claude Code's structured patch, usage counts cached prompt tokens, a model change no longer restarts the process, and a conversation that has gone missing is replaced and the prompt resent.
  • Codex: file changes list their paths, MCP calls show the server, the tool, the error and the output, a non-zero exit fails the card, and the probe resolves the binary the SDK ships instead of demanding one on PATH.

The lesson I'm keeping

A test is worth exactly what its inputs are worth. For anything whose shape I don't own — someone else's SDK, a protocol, a webhook — recording one real session and committing it beats ten fixtures written from the documentation. The recorder took an afternoon and found something wrong in every single adapter.

It's all in the open: Glassys on GitHub. If you haven't met it, this is what it is, and this was the week I spent locking it down.