Skip to content

Commit 2fb34b8

Browse files
committed
docs(settings): add practical guides for extending and testing
Walk-throughs for adding a tool, slash command, provider, permission policy, glue task, and microbench, plus the test conventions used in the existing suite. Separates "what is this" (already shipped) from "how do I work in it" so neither doc gets unwieldy.
1 parent 7b3dd01 commit 2fb34b8

2 files changed

Lines changed: 300 additions & 0 deletions

File tree

.settings/extending.md

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
# Extending the CLI
2+
3+
How to add the three most common things. Patterns are stable; if you need to
4+
deviate, find an existing example first and follow it.
5+
6+
## Add a tool
7+
8+
Tools are self-contained modules in `src/tools/`. Each one exports a `Tool`
9+
that the agent registry picks up. The canonical small example is
10+
`src/tools/glob.ts`. Copy its shape:
11+
12+
```ts
13+
// src/tools/my-tool.ts
14+
import { Type } from "@sinclair/typebox";
15+
import type { Tool } from "./types.js";
16+
17+
export const myTool: Tool = {
18+
name: "my_tool",
19+
description: "One-line summary the model sees. Be specific; this drives selection.",
20+
parameters: Type.Object({
21+
target: Type.String({ description: "What to operate on" }),
22+
mode: Type.Optional(Type.Union([Type.Literal("fast"), Type.Literal("safe")])),
23+
}),
24+
effects: ["reads_fs"], // or ["writes_fs"], ["runs_shell"], ["network"]
25+
execute: async (args, ctx) => {
26+
// ctx.cwd, ctx.permissions, ctx.abortSignal, ctx.tasks, ctx.fileStateCache
27+
// Return a string (default), or { content, isError } for richer outputs.
28+
return "result string";
29+
},
30+
};
31+
```
32+
33+
Then register it in `src/tools/registry.ts` (alphabetical, please):
34+
35+
```ts
36+
import { myTool } from "./my-tool.js";
37+
// ...
38+
export const BUILTIN_TOOLS = [
39+
// ...
40+
myTool,
41+
];
42+
```
43+
44+
**Test it.** Drop `src/tools/my-tool.test.ts` next to the source. Use
45+
`makeToolContext` from `src/tools/__test__/mock-tool-context.ts` for the
46+
context fixture — don't hand-roll `{} as any`. Vitest convention is
47+
`describe(toolName, () => { ... })`.
48+
49+
**Display polish.** Add a present-tense + past-tense label in
50+
`src/ui/tool-labels.ts`:
51+
52+
```ts
53+
// In toolActionLabel:
54+
case "my_tool":
55+
return `Doing ${displayPath(str("target"))}`;
56+
// In toolActionPast:
57+
case "my_tool":
58+
return `Did ${displayPath(str("target"))}`;
59+
```
60+
61+
If your tool reads files and produces deterministic small output that
62+
collapses well in runs, add it to `COLLAPSIBLE_READ_TOOLS` in
63+
`src/ui/tool-call-line.tsx`.
64+
65+
## Add a slash command
66+
67+
Commands live in `src/commands/builtins.ts`. Add one:
68+
69+
```ts
70+
const myCommand: Command = {
71+
name: "my-thing",
72+
aliases: ["mt"],
73+
description: "What this does, ≤ 60 chars — shown in /help.",
74+
// mutates: true, // only if it changes session state (clear, compact)
75+
handler: async (args, ctx) => {
76+
// ctx.bundle, ctx.state, ctx.emit, ctx.clearDisplay, ctx.exit, ctx.registry
77+
ctx.emit("did the thing");
78+
return { handled: true };
79+
},
80+
};
81+
```
82+
83+
Then add it to `BUILTIN_COMMANDS` (alphabetical). The registry handles
84+
parsing, dispatch, unknown-command typo suggestions, and the help listing.
85+
86+
Slash commands that need to render persistent assistant-style messages
87+
should call `bundle.appendSyntheticAssistantMessage(...)` (TODO: check the
88+
exact name in `src/agent/agent.ts`) rather than `emit()`, which is for
89+
short status lines.
90+
91+
**Test it.** `src/commands/registry.test.ts` shows the pattern. Use
92+
`fakeCtx()` to construct the context; only fill the fields your handler
93+
reads.
94+
95+
## Add an LLM provider
96+
97+
The agent never talks to a provider directly — `@earendil-works/pi-ai`
98+
does. If the provider has an OpenAI-compatible API, you usually don't need
99+
to add anything: users set `OPENAI_BASE_URL` + `OPENAI_API_KEY` and the
100+
existing path handles it.
101+
102+
For a provider that needs a new protocol adapter, the work lives upstream
103+
in pi-ai, not here. Open an issue / PR on `@earendil-works/pi-ai` first;
104+
once it lands, expose it in our config layer:
105+
106+
- Add a model entry to `src/config/models.ts` (look for the existing
107+
Groq / Anthropic / OpenRouter entries as templates).
108+
- Add credential resolution to `src/auth/credentials.ts` if it needs
109+
a non-standard env var or OAuth flow.
110+
- Add provider-specific display tweaks (icon, label, etc.) if the
111+
default rendering looks off.
112+
113+
The first-run wizard (`src/ui/FirstRunSetup.tsx`) auto-detects available
114+
provider credentials and offers them as options — no hardcoded provider
115+
list to maintain in the wizard.
116+
117+
## Add a permission policy
118+
119+
Effect-based, not tool-name-based. Policies live in
120+
`src/permissions/policies.ts`. To pre-approve a category:
121+
122+
```ts
123+
{
124+
match: (req) => req.effects.includes("reads_fs") && isInsideCwd(req.args.path),
125+
decision: "allow",
126+
reason: "read-only access inside cwd is auto-approved",
127+
}
128+
```
129+
130+
To pre-deny:
131+
132+
```ts
133+
{
134+
match: (req) => req.effects.includes("runs_shell") && isDangerous(req.args.command),
135+
decision: "deny",
136+
reason: "dangerous shell command",
137+
}
138+
```
139+
140+
The store evaluates policies in order until one matches; otherwise the
141+
user is prompted.
142+
143+
## Add a glue task
144+
145+
Glue is the cheap/fast sidecar model. Routing, narration, plan-mode Q&A,
146+
prompt suggestions all live here.
147+
148+
A glue call should:
149+
- Have a graceful fallback when the glue model is unconfigured or fails.
150+
- Be cheap to abort — wrap the network call with `AbortController`.
151+
- Never feed its output into the main agent's context. Glue is for *UI
152+
enrichment*, not agent reasoning.
153+
154+
The pattern in `src/agent/prompt-suggestion.ts` is the template: small
155+
prompt, abort signal threaded through, silent failure mode.
156+
157+
## Add a microbenchmark
158+
159+
If you're touching a hot path (reducer, coalesce, diff, wrap), add a bench
160+
case to `src/agent/events.bench.ts` (or a peer `*.bench.ts` file).
161+
`npm run bench:micro` runs them. They're not part of `npm test` so they
162+
don't slow CI — but they give us a baseline number to spot regressions.
163+
164+
## What NOT to extend
165+
166+
- **Don't add a new chat-state field unless every reducer branch handles it.**
167+
ChatState is the single source of truth for what the UI shows; partial
168+
fields produce mysterious render bugs.
169+
- **Don't reach into pi-agent-core's internals.** If you need something the
170+
public API doesn't give you, the fix is upstream.
171+
- **Don't add a separate persistence file format for new state.** Sessions,
172+
history, memory, credentials, and tasks each have one canonical JSON
173+
schema. Add fields, don't add sidecars.

.settings/testing.md

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
# Testing
2+
3+
## Stack
4+
5+
- **Runner**: vitest 2.x. Config: `vitest.config.ts`. Tests are `*.test.ts`
6+
/ `*.test.tsx` colocated with source.
7+
- **Bench runner**: vitest's built-in bench. Files are `*.bench.ts`. Run
8+
with `npm run bench:micro` (separate from `npm test`).
9+
- **Lint**: biome (`biome.json`). Run with `npm run lint`, autofix with
10+
`npm run lint:fix`.
11+
- **Type check**: `npm run typecheck` (`tsc --noEmit`).
12+
- **Full pre-publish gate**: `npm run check` (typecheck + lint + test).
13+
14+
## Run them
15+
16+
```sh
17+
npm test # all unit tests
18+
npm test -- --watch # watch mode (or `npm run test:watch`)
19+
npm test -- src/agent # filter by path
20+
npm test -- -t "user-prompt" # filter by test name
21+
npm run bench:micro # microbenchmarks
22+
npm run bench # end-to-end LLM benchmarks (needs API key)
23+
```
24+
25+
## Conventions
26+
27+
### Colocation
28+
29+
`src/foo/bar.ts``src/foo/bar.test.ts`. Always next to the source.
30+
Centralized `test/` folders are an anti-pattern in this repo.
31+
32+
### Test names describe behavior, not the function
33+
34+
Good: `it("appends a user message and flips status to thinking")`
35+
Bad: `it("user-prompt action works")`
36+
37+
The test name is the spec. Write it so a failure tells you what broke
38+
without reading the test body.
39+
40+
### Use the typed mock context, not `{} as any`
41+
42+
`src/tools/__test__/mock-tool-context.ts` provides `makeToolContext()` for
43+
constructing a tool context with sensible defaults you can override field
44+
by field. Don't hand-roll. If a tool needs a context field that mock
45+
doesn't have, add it to the factory.
46+
47+
For commands, `src/commands/registry.test.ts::fakeCtx()` is the pattern.
48+
49+
### Don't mock the filesystem in filesystem tools
50+
51+
`vitest` runs tests in parallel by default, but the filesystem tools use
52+
`mkdtemp` and clean up in `afterEach`. Look at `src/tools/read-file.test.ts`
53+
or `src/tools/edit-file.test.ts` for the template. Mocked filesystems
54+
historically hide real bugs (path normalization, symlink handling, race
55+
conditions) — we don't.
56+
57+
### E2E: use pi-ai's faux provider
58+
59+
`src/agent/__test__/agent-e2e.test.ts` is the reference. `registerFauxProvider`
60+
from `@earendil-works/pi-ai` lets you drive the full agent loop —
61+
real reducer, real tool execution, real session persistence — with a
62+
scripted LLM response stream. No network. Deterministic.
63+
64+
When you need an integration test that crosses agent + tools + UI state,
65+
this is the right tool. Don't reinvent it.
66+
67+
### Reducers and pure functions get exhaustive tests
68+
69+
`src/agent/events.test.ts` covers every Action variant and every
70+
AgentEvent type. If you add a branch, add a test in the same PR. The
71+
reducer is load-bearing — every state transition for every user goes
72+
through it.
73+
74+
### The TUI doesn't have automated tests yet
75+
76+
Render-layer testing (snapshotting `<App>` output, simulating keystrokes
77+
via `ink-testing-library`) is a known gap. Don't claim a UI change
78+
"works" because tests pass — type-check ≠ feature-correct for the
79+
rendering layer. Manually exercise the change in a real terminal.
80+
81+
If you're adding a TUI test framework, `ink-testing-library` is the
82+
right choice. Open a PR; we want this.
83+
84+
### Network-dependent tests need a guard
85+
86+
Anything that hits a real API (web_fetch, web_search, OAuth refresh)
87+
needs an `it.skipIf(!process.env.NETWORK_TESTS_ENABLED)` or equivalent —
88+
they can't be in the default `npm test` path because contributors
89+
without API keys would fail their pre-commit.
90+
91+
`src/tools/web-fetch.test.ts` uses a local mock HTTP server. That's the
92+
preferred pattern; only escape to live API if there's no other way.
93+
94+
### Benchmarks don't replace tests
95+
96+
A bench tells you *how fast*. A test tells you *whether correct*. If you
97+
have only a bench, you don't know it's correct. If you have only tests,
98+
you don't know it's fast. The reducer has both for a reason.
99+
100+
## What's worth testing first
101+
102+
When you walk into an unfamiliar module and want confidence:
103+
104+
1. **Pure functions with branchy logic**. `parseAnswer` (plan), `matchOption`
105+
(plan), `mergeUsage` (reducer helper), `displayPath` (UI), `truncate`,
106+
`wrapText`, anything with a switch statement.
107+
2. **State machines.** The reducer. The session router. The OAuth state
108+
transitions.
109+
3. **Parsers and serializers.** `extractJson`, `parseRunArgs`, the message
110+
stream coalescer.
111+
4. **Filesystem-touching tools.** Read, edit, multi-edit, write, glob, grep.
112+
Real mkdtemp, real assertions, no mocks.
113+
5. **Permission policies.** Effect matching + path / URL pattern matching.
114+
115+
What's hard to test in this codebase:
116+
117+
- The interactive TUI render output (no harness yet — see above).
118+
- Real-network tools (need either VCR-style fixtures or skip guards).
119+
- Streaming timing (the 16ms coalesce). Can be tested in microbench but
120+
hard to assert on in unit tests.
121+
122+
## CI
123+
124+
Pre-publish runs `npm run check` automatically (via `prepublishOnly`).
125+
There is no separate GitHub Actions config committed to this repo yet —
126+
the test gate is local. If you're contributing externally, run `npm run check`
127+
before opening a PR; it's the same gate `npm publish` enforces.

0 commit comments

Comments
 (0)