This document explains how the codebase is structured, how to work with it locally, and how the test suite is organised.
For contribution rules (PR process, code standards, how to add an adapter) see CONTRIBUTING.md. For module responsibilities and design decisions see architecture.md.
- Local setup
- Project structure
- Test strategy
- Running specific tests
- Adding a new adapter
- Event flow walkthrough
- Adapters quick reference
git clone https://github.com/casabre/coding-agent-a2a
cd coding-agent-a2a
npm install
npm run build # compile TypeScript → dist/
cp .env.example .env # then edit AGENT_ADAPTER and AGENT_REPO_PATH
npm run dev # tsx watch — recompiles on saveAll npm scripts:
| Script | Description |
|---|---|
npm run build |
Compile TypeScript to dist/ |
npm run typecheck |
Type-check without emitting (fast) |
npm run dev |
Watch mode with hot reload |
npm start |
Run the compiled build |
npm test |
Run unit + integration tests once |
npm run test:watch |
Rerun tests on file change |
npm run test:coverage |
Run with coverage report (enforces 100%) |
npm run test:e2e |
End-to-end tests (requires CLI on PATH) |
npm run lint |
ESLint |
src/
├── index.ts Entry point
├── config.ts Env-var parsing → Config
├── types.ts Shared interfaces (Config)
├── combined-server.ts Express app factory (A2A + MCP/HTTP)
├── server.ts A2A-only Express app factory
├── agent-card.ts A2A AgentCard builder
├── cursor-executor.ts A2A AgentExecutor (spawns runner per task)
├── cursor-runner.ts Child-process manager + NDJSON stream parser
├── a2a-mapper.ts Pure: AgentEvent → A2A SDK event(s)
├── event-bus.ts Process-wide pub/sub (EventBus singleton)
│
├── adapters/
│ ├── base.ts CodingAgentAdapter interface + AgentEvent union
│ ├── cursor.ts Cursor adapter
│ ├── claude-code.ts Claude Code adapter
│ ├── index.ts Adapter registry (resolveAdapter)
│ └── ndjson-helpers.ts Shared NDJSON event parser
│
└── mcp/
├── server.ts createMcpServer
├── tools.ts registerTools (5 MCP tools)
├── task-manager.ts McpTaskManager (UUID job store)
├── stdio-transport.ts Connect McpServer to stdio
└── http-transport.ts StreamableHTTPServerTransport factory
tests/
├── unit/
│ ├── a2a-mapper.test.ts
│ ├── config.test.ts
│ ├── cursor-executor.test.ts
│ ├── cursor-runner.test.ts
│ ├── event-bus.test.ts
│ ├── adapters/
│ │ ├── cursor.test.ts
│ │ ├── claude-code.test.ts
│ │ ├── ndjson-helpers.test.ts
│ │ └── index.test.ts
│ └── mcp/
│ ├── server.test.ts
│ ├── task-manager.test.ts
│ └── tools.test.ts
├── integration/
│ ├── server.test.ts
│ ├── combined-server.test.ts
│ └── stdio-transport.test.ts
└── e2e/
└── full-flow.test.ts (requires CURSOR_E2E=1)
The test suite follows a strict pyramid: many unit tests, a handful of integration tests, and optional e2e tests guarded behind an env var.
Each src/ module has a corresponding unit test. External I/O is mocked:
-
cursor-runner.test.ts—node:child_process.spawnis mocked viavi.mock. A controllableEventEmitterwith mockstdout/stderr/stdinstreams stands in for the real child process. Tests verify NDJSON parsing, timeout logic, idle-kill, SIGTERM/SIGKILL sequencing. -
a2a-mapper.test.ts— No mocks. The mapper is a pure function; all 17 cases assert the exact A2A event shape returned for eachAgentEventvariant. -
cursor-executor.test.ts— Bothcursor-runner.jsanda2a-mapper.jsare mocked. Tests assert that the executor wires events correctly, handles cancellation, and cleans up the runner map. -
mcp/task-manager.test.ts—cursor-runner.jsandevent-bus.jsare mocked. Tests cover the full job lifecycle:startJob→pollJob→getResult/cancelJob. -
mcp/tools.test.ts—McpTaskManageris a mock object. Tests call each tool via an in-memory MCP client (InMemoryTransport.createLinkedPair()) and assert the JSON responses. -
config.test.ts— Manipulatesprocess.envdirectly. Tests cover all env vars, edge cases (empty string, invalid numbers), and theLOG_LEVELpassthrough. -
event-bus.test.ts— TestsemitJobEvent,onJobEvent,onAllJobEvents, and that unsubscribe functions work.
-
server.test.ts— Starts the full Express app withsupertest. Uses a mockAgentExecutorthat publishes controlled event sequences. Tests all HTTP surface: agent card, JSON-RPC error handling, A2A methods. -
combined-server.test.ts— Starts the combined A2A + MCP/HTTP app on a random port. Tests that both protocols respond correctly on the same server instance, using a realClientfrom@modelcontextprotocol/sdk. -
stdio-transport.test.ts— Tests the MCP server tools via in-memory transport. Verifiescoding_agent_inforeturns the correct adapter and that tool names match the expected set.
vitest.config.ts sets thresholds: { lines: 100, branches: 100, functions: 100 }. CI fails if any threshold is not met. Type-only files (types.ts, adapters/base.ts) and the entry point (index.ts) are excluded from the thresholds.
Several tests mock CursorRunner using Vitest's module mock with a shared singleton:
vi.mock('../../src/cursor-runner.js', async () => {
const { EventEmitter } = await import('node:events');
const instance = new EventEmitter() as EventEmitter & {
start: ReturnType<typeof vi.fn>;
cancel: ReturnType<typeof vi.fn>;
};
instance.start = vi.fn();
instance.cancel = vi.fn();
const CursorRunner = vi.fn(() => instance);
return { CursorRunner, __instance: instance };
});The __instance export lets tests emit events on the mock runner and assert downstream behaviour:
getMockRunner().emit('agent-event', { kind: 'thinking', text: 'hello' });
expect(vi.mocked(eventBus.emitJobEvent)).toHaveBeenCalledWith(jobId, { kind: 'thinking', text: 'hello' });Run a single test file:
npx vitest run tests/unit/a2a-mapper.test.tsRun tests matching a name pattern:
npx vitest run -t "pollJob"Watch a single file:
npx vitest tests/unit/mcp/task-manager.test.tsSee CONTRIBUTING.md § Adding a new adapter for the full step-by-step guide.
In brief:
- Create
src/adapters/my-agent.tsimplementingCodingAgentAdapter. - Register it in
src/adapters/index.ts. - Add
tests/unit/adapters/my-agent.test.tswith 100% coverage. - Add the adapter name to
AGENT_ADAPTERdocs indocs/deployment.md.
The shared parseSharedNdjsonEvent in ndjson-helpers.ts handles the common system/init / assistant / tool_use / tool_result / result / error schema used by both Cursor and Claude Code. If your CLI uses the same schema, reuse it. If not, implement parseEvent directly.
Here is how a single task travels through the system from MCP call to CLI and back.
1. MCP host → coding_agent_run { task: "refactor auth" }
2. tools.ts → taskManager.startJob("refactor auth")
3. McpTaskManager:
a. Generates jobId = "uuid-1234"
b. Creates CursorRunner({ task, adapter, config })
c. Registers listeners on runner
d. Calls runner.start()
4. CursorRunner:
a. Calls adapter.resolveBinary() → "cursor-agent"
b. Calls adapter.buildArgv({ task, repoPath, ... }) →
["--print", "--output-format", "stream-json", "-f", "refactor auth"]
c. Spawns: child_process.spawn("cursor-agent", [...args], { cwd, shell: false })
5. CLI writes to stdout:
{"type":"system/init","model":"claude-opus-4-5"}
6. CursorRunner:
a. Reads buffered stdout line-by-line
b. For each line: adapter.isApprovalPrompt(line) → false
c. adapter.parseEvent(line) → { kind: 'init', model: 'claude-opus-4-5' }
d. Emits 'agent-event', { kind: 'init', model: 'claude-opus-4-5' }
7. McpTaskManager receives 'agent-event':
a. job.events.push(event)
b. eventBus.emitJobEvent(jobId, event)
8. MCP host → coding_agent_poll { jobId: "uuid-1234", sinceLine: 0 }
→ taskManager.pollJob("uuid-1234", 0)
← { events: [{ kind: 'init', ... }], done: false }
9. ... more events arrive ...
10. CLI writes: {"type":"result","cost":{"input_tokens":1200,"output_tokens":300}}
→ runner emits 'agent-event', { kind: 'done', summary: '', stats: { inputTokens: 1200, ... } }
→ job.events.push(event)
11. CLI exits 0 → runner emits 'done', 0, ""
→ job.done = true; job.exitCode = 0
12. MCP host → coding_agent_result { jobId: "uuid-1234" }
→ taskManager.getResult("uuid-1234")
← { summary: '', stats: { inputTokens: 1200, ... }, done: true }
→ job removed from _jobs map
| Adapter | AGENT_ADAPTER value |
Binary env override | Force flag | Model flag |
|---|---|---|---|---|
| Cursor | cursor |
CURSOR_AGENT_PATH |
-f |
-m <model> |
| Claude Code | claude-code |
CLAUDE_CODE_PATH |
--dangerously-skip-permissions |
--model <model> |
Both adapters use the same --print --output-format stream-json flags to enable NDJSON streaming.