@moikapy/lich 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,234 @@
1
+ # The Agent Loop
2
+
3
+ `run_conversation` in [`src/agent/loop.ts`](../../src/agent/loop.ts) is the
4
+ heart of lich: a think-act-observe loop that drives a chat model, executes the
5
+ tools it requests, feeds results back, and compresses history when the context
6
+ budget demands it. It depends only on two narrow structural interfaces -
7
+ `ChatFn` and `ToolRunner` - never on the provider router or the tool executor
8
+ directly (see [overview](./overview.md#the-dependency-inversion-story)).
9
+
10
+ ## State machine of one turn
11
+
12
+ ```mermaid
13
+ stateDiagram-v2
14
+ [*] --> TurnStart
15
+ TurnStart : turn_start emitted
16
+ TurnStart --> abort_if_signal
17
+ state abort_if_signal <<choice>>
18
+ abort_if_signal --> Aborted : signal already aborted
19
+ abort_if_signal --> CompressCheck : not aborted
20
+ CompressCheck : compression needed?
21
+ CompressCheck --> Compress : estimate >= budget * threshold
22
+ CompressCheck --> LlmStart : below threshold
23
+ Compress --> LlmStart
24
+ Compress : compress_start and compress_end
25
+ LlmStart : llm_start emitted
26
+ LlmStart --> LlmEnd : one chat call
27
+ LlmEnd : llm_end emitted, assistant pushed
28
+ LlmEnd --> has_calls
29
+ state has_calls <<choice>>
30
+ has_calls --> RunTools : tool calls present
31
+ has_calls --> Final : no tool calls
32
+ RunTools : tool_call_start and tool_call_end pairs
33
+ RunTools --> TurnStart : next turn
34
+ Final : final then turn_end
35
+ Final --> [*]
36
+ Aborted : error event, stopped_reason = aborted
37
+ Aborted --> [*]
38
+ note right of Final
39
+ budget path: after the last allowed turn
40
+ budget_exhausted then turn_end, stopped_reason = budget
41
+ end note
42
+ ```
43
+
44
+ ## The `run_conversation` contract
45
+
46
+ ```ts
47
+ export interface LoopOutcome {
48
+ messages: Message[];
49
+ final: AssistantMessage | undefined;
50
+ result: ChatResult | undefined;
51
+ turns_used: number;
52
+ stopped_reason: "final" | "budget" | "aborted";
53
+ }
54
+ ```
55
+
56
+ (src/agent/loop.ts)
57
+
58
+ **Parameters** (`LoopParams`): `system_prompt?`, `max_turns` (required),
59
+ `temperature?`, `max_tokens?`, `context_budget_tokens?`,
60
+ `compress_threshold?`, `signal?`.
61
+
62
+ **`max_turns` is an LLM-call budget, not a tool budget.** Each iteration makes
63
+ exactly one LLM call; the tool executions between turns are free - a turn that
64
+ calls three tools still consumes one turn. A model that always asks for tools
65
+ will run out of budget even though the tools all succeeded.
66
+
67
+ **Stopping conditions**, exactly as implemented:
68
+
69
+ | `stopped_reason` | When it fires |
70
+ | --- | --- |
71
+ | `"final"` | A turn's assistant message has an empty `tool_calls` array (or none). The message is emitted as `final` and the loop returns immediately, even if it is turn 1. |
72
+ | `"aborted"` | The abort check at the **top** of a turn sees `signal.aborted` before any LLM call. The loop emits an `error` event carrying `new DOMException("agent loop aborted", "AbortError")` and returns with `result: undefined`. |
73
+ | `"budget"` | The `for` loop over `1..max_turns` completes while every turn kept requesting tools. After the last allowed turn, `budget_exhausted` then `turn_end` are emitted. |
74
+
75
+ Note the asymmetry: abort is checked only at the loop top, so a signal that
76
+ fires mid-turn still lets the current LLM call and tool batch finish before
77
+ the next turn notices. Provider-level aborts can surface earlier as thrown
78
+ errors (see [Error propagation](#error-propagation) below).
79
+
80
+ `turns_used` counts LLM calls actually made: `turn` on the final path,
81
+ `turn - 1` on abort (the turn that was about to start never ran), and
82
+ `max_turns` on the budget path.
83
+
84
+ ## Event stream
85
+
86
+ All events flow through `AgentEmitter` (`src/agent/events.ts`), a typed
87
+ emitter over a discriminated `AgentEvent` union. Handler errors are logged and
88
+ swallowed; handlers may unsubscribe mid-emit (the emitter iterates a snapshot).
89
+
90
+ | Event | Payload | Emitted when | Order guarantee |
91
+ | --- | --- | --- | --- |
92
+ | `turn_start` | `{ turn }` | Start of each iteration | First event of a turn |
93
+ | `compress_start` | `{ estimated_tokens }` | Compression begins | After `turn_start`, before `llm_start` |
94
+ | `compress_end` | `{ summary_chars }` | Compression rewrote history | Always paired after `compress_start` |
95
+ | `llm_start` | `{ turn }` | Just before the chat call | Exactly one per LLM call |
96
+ | `llm_end` | `{ turn, result }` | Chat call resolved | Pairs with `llm_start`; never fires if the call throws |
97
+ | `tool_call_start` | `{ turn, call }` | Before each tool executes | After `llm_end`, sequential per call |
98
+ | `tool_call_end` | `{ turn, call, result }` | After that tool resolves | Pairs with its `tool_call_start` |
99
+ | `final` | `{ message, result }` | A turn produced no tool calls | At most once per run; only on a real final |
100
+ | `budget_exhausted` | `{ turns_used }` | Loop exits without a final | Follows the last `tool_call_end` |
101
+ | `turn_end` | `{ turn }` | Last event of a turn | After `final` **or** after `budget_exhausted` |
102
+ | `error` | `{ error }` | Chat call failed (logged, rethrown) or loop aborted | Followed by no other events |
103
+
104
+ Verified ordering from `test/loop.test.ts` scenario A:
105
+
106
+ ```text
107
+ turn_start, llm_start, llm_end, tool_call_start, tool_call_end,
108
+ turn_start, llm_start, llm_end, final, turn_end
109
+ ```
110
+
111
+ ## History semantics
112
+
113
+ `run_conversation` never mutates the caller's array. `seed_system_prompt`
114
+ copies it (`[...messages]`) and then applies the system prompt rules:
115
+
116
+ 1. **No system message in history** - prepend `{ role: "system", content }`.
117
+ 2. **A system message with identical content** - return the copy unchanged.
118
+ 3. **A system message with different content** - replace it in place.
119
+
120
+ During the run, the history array *is* mutated in place (`push` for assistant
121
+ and tool messages, refill for compression) - the loop treats it as its own
122
+ working copy. Compression rewrites it via `history.length = 0` followed by a
123
+ refill from the compressed outcome, so callers holding the returned `messages`
124
+ array see the final, post-compression transcript.
125
+
126
+ ### Compression gating
127
+
128
+ Compression is skipped when any of these hold:
129
+
130
+ - `context_budget_tokens` is undefined (the loop calls `compress_if_needed`,
131
+ which returns immediately).
132
+ - `should_compress` is false: `estimate_messages_tokens(history) <
133
+ budget * threshold` (threshold defaults to 0.8).
134
+ - The history has **8 or fewer non-system messages** (`KEEP_RECENT_TURNS`).
135
+ Compressing would have nothing left to summarize: the partition below keeps
136
+ the last 8 verbatim, and a run with only that many messages has no `older`
137
+ segment.
138
+
139
+ ## Compression internals
140
+
141
+ `compress_messages` (`src/context/compressor.ts`) partitions the history:
142
+
143
+ ```text
144
+ [ system messages ... | older non-system | recent 8 non-system ]
145
+ ```
146
+
147
+ The `older` slice is rendered to a transcript
148
+ (`[role] content tool_calls=...` per line, truncated at 24 000 chars) and sent
149
+ to the same `ChatFn` with a fixed system prompt:
150
+
151
+ ```ts
152
+ export const COMPRESSION_SYSTEM_PROMPT =
153
+ "You compress agent conversation history into terse factual summaries. Preserve: goals, decisions, file paths, commands run, errors, open questions. Output plain text only.";
154
+ ```
155
+
156
+ (src/context/compressor.ts)
157
+
158
+ The summary comes back as a single `user` message:
159
+ `[context summary of earlier turns]\n<summary>\n[end summary]`. The result is
160
+ `[...system, summary, ...recent]`.
161
+
162
+ **Best-effort fallback.** If the summarizer call throws (including
163
+ `ProviderError`), the failure is logged at warn level and the original
164
+ messages are returned unchanged (`summary_chars: 0`). Compression is an
165
+ optimization, never a correctness requirement. The next turn may still exceed
166
+ the budget; a provider-side context overflow then surfaces as an `overflow`
167
+ error (see [providers](./providers.md#error-taxonomy)).
168
+
169
+ **Token estimate.** `estimate_text_tokens` is `ceil(chars / 4)`; assistant
170
+ messages add the JSON-stringified `tool_calls`, tool messages add a flat
171
+ 8-token overhead (`src/context/tokens.ts`). The estimate is intentionally
172
+ coarse - see the trade-off note in [overview](./overview.md#design-trade-offs).
173
+
174
+ ## Sessions
175
+
176
+ `src/session/store.ts` appends JSONL records to
177
+ `<session_dir>/<timestamp>-<counter>[-<label>].jsonl`:
178
+
179
+ ```jsonc
180
+ // kind "meta": arbitrary metadata records
181
+ { "ts": "2026-09-14T05:00:00.000Z", "kind": "meta",
182
+ "meta": { "event": "run_start", "input_chars": 24, "history_size": 3 } }
183
+ // kind "message": one transcript message
184
+ { "ts": "2026-09-14T05:00:01.000Z", "kind": "message",
185
+ "message": { "role": "assistant", "content": "done" } }
186
+ ```
187
+
188
+ What gets persisted, per run (`Agent.persist_session`): a `run_start` meta
189
+ record, one `message` record per outcome message (including the seeded system
190
+ message and tool messages), and a `budget_exhausted` meta record when the run
191
+ stopped on budget. Everything is best-effort: any error logs a warning and
192
+ returns `session_path: undefined` instead of failing the run.
193
+
194
+ `read_session_messages(path)` parses a file back into `Message[]`: per line it
195
+ JSON-parses leniently, accepts only records with a `kind: "message"`-shaped
196
+ `message` whose `role` is one of the four known roles, and silently skips
197
+ everything else. Missing files parse to an empty array.
198
+
199
+ ## Error propagation
200
+
201
+ The loop has an explicit asymmetry between provider errors and tool errors:
202
+
203
+ - **Provider errors are fatal and rethrown.** `call_chat` catches, logs,
204
+ emits `error`, and rethrows - even `ProviderError`. The loop never swallows
205
+ them. This is deliberate: retry/failover policy lives *below* the loop
206
+ (in the router and `run_with_retries`), so by the time an error reaches the
207
+ loop it has exhausted every provider and every retry. Pretending otherwise
208
+ would leave the caller with a half-run history and no way to distinguish
209
+ "model failed" from "model answered".
210
+
211
+ - **Tool errors are data, not exceptions.** `run_tool_calls` maps a failed
212
+ `ToolResult` onto a `tool` message with `is_error: true` and content built
213
+ by `format_tool_result_content`:
214
+ `JSON.stringify({ ok: false, output, error })` on failure, raw `output`
215
+ otherwise. The model sees the error text and can adapt (retry with
216
+ different arguments, tell the user, pick another tool). A tool failing
217
+ never stops the run.
218
+
219
+ The executor enforces the same shape one level down: it never throws either
220
+ (see [tools](./tools.md#executor-semantics)). Together this means the only
221
+ exceptions escaping `run_conversation` are provider/network failures after
222
+ full failover, and the documented abort error from the loop top.
223
+
224
+ ### Abort semantics
225
+
226
+ - The loop checks `params.signal?.aborted` at the top of each turn; an abort
227
+ mid-turn is only observed on the next turn boundary.
228
+ - A chat call aborted mid-flight throws (fetch `AbortError`), which after
229
+ classification/retry surfaces from `call_chat` as a thrown error - the loop
230
+ does not convert it into a stopped_reason; callers catching `AbortError`
231
+ see it directly.
232
+ - The router stops walking to the next provider as soon as the caller signal
233
+ is aborted, and retries abort out of their backoff sleep
234
+ (`src/providers/failover.ts`, `src/providers/router.ts`).
@@ -0,0 +1,284 @@
1
+ # Extending Lich
2
+
3
+ Hands-on recipes for the three extension axes: builtin tools, providers, and
4
+ gateway platforms - plus the TUI internals, the conventions that hold the
5
+ codebase together, and how the test suite is organized. Read
6
+ [tools](./tools.md) and [providers](./providers.md) first for the contracts.
7
+
8
+ ## Add a builtin tool
9
+
10
+ A builtin is one file in `src/tools/builtin/` following the
11
+ [`terminal.ts`](../../src/tools/builtin/terminal.ts) shape, plus one line in
12
+ `src/tools/builtin/index.ts`. Walkthrough with a complete, useful example:
13
+ a `hash_text` tool (SHA-256 through Node's `crypto`).
14
+
15
+ **Step 1 - create `src/tools/builtin/hash_text.ts`:**
16
+
17
+ ```ts
18
+ import { createHash } from "node:crypto";
19
+ import type { JsonSchemaObject } from "../../util/json_schema.js";
20
+ import { capture_errors, require_string_arg } from "../guard.js";
21
+ import type { Tool } from "../types.js";
22
+
23
+ const parameters: JsonSchemaObject = {
24
+ type: "object",
25
+ properties: {
26
+ text: { type: "string", description: "Text to hash with sha256" },
27
+ algorithm: { type: "string", description: "sha256 (default) or sha1 or md5" },
28
+ },
29
+ required: ["text"],
30
+ additionalProperties: false,
31
+ };
32
+
33
+ const ALGORITHMS = new Set(["sha256", "sha1", "md5"]);
34
+
35
+ async function hash_text(args: Record<string, unknown>): Promise<string> {
36
+ const text = require_string_arg(args, "text");
37
+ const algorithm = ALGORITHMS.has(String(args.algorithm)) === true
38
+ ? String(args.algorithm)
39
+ : "sha256";
40
+ return createHash(algorithm).update(text, "utf8").digest("hex");
41
+ }
42
+
43
+ export const hash_text_tool: Tool = {
44
+ name: "hash_text",
45
+ description: "Hash text with sha256 (default), sha1, or md5.",
46
+ parameters,
47
+ execute: async (args) => capture_errors(async () => ({ ok: true, output: await hash_text(args) })),
48
+ };
49
+ ```
50
+
51
+ Note the conventions this mirrors from `terminal.ts`: module-level
52
+ `parameters` constant, argument reads through the guard coercion helpers,
53
+ one pure `hash_text` function the test can target, and `execute` wrapped in
54
+ `capture_errors` so the never-throw contract holds even if a guard throws.
55
+
56
+ **Step 2 - register in `src/tools/builtin/index.ts`:** add the import and add
57
+ `hash_text_tool` to the `builtin_tools` array. `register_builtin_tools`
58
+ registers the whole set via `builtin_toolset`; nothing else changes.
59
+
60
+ **Step 3 - test it** in `test/tools.test.ts` (or a sibling), following the
61
+ existing pattern of calling the tool directly with a hand-built context:
62
+
63
+ ```ts
64
+ import { hash_text_tool } from "../src/tools/builtin/hash_text.js";
65
+
66
+ it("hash_text returns the sha256 hex digest", async () => {
67
+ const result = await hash_text_tool.execute(
68
+ { text: "lich" },
69
+ { work_dir: "/tmp", env: {} },
70
+ );
71
+ expect(result.ok).toBe(true);
72
+ expect(result.output).toBe(
73
+ "1e3c2f5c4ba9d476a3b93e2c1a7f2b8d9e0c1a2b3c4d5e6f708192a3b4c5d6e7",
74
+ );
75
+ });
76
+ ```
77
+
78
+ (The digest literal is illustrative - compute the real one once and freeze it
79
+ into the test.) The executor-level suite in `test/tools.test.ts` covers the
80
+ shared machinery (timeout, unknown tool, clamping), so a new builtin only
81
+ needs mapping and behavior tests like the one above.
82
+
83
+ If the tool needs the LLM-side wiring exercised too, reuse the loop test
84
+ fakes: a `ToolRunner` stub in `test/loop.test.ts` style, or run the full
85
+ public API with the mock-provider pattern from `test/e2e.test.ts`.
86
+
87
+ ## Add a provider
88
+
89
+ Implement `LLMProvider` (see [providers](./providers.md#the-llmprovider-contract))
90
+ and register it in exactly **three touch points** - the same three that
91
+ adding ollama required:
92
+
93
+ 1. **The client file.** Use [`ollama.ts`](../../src/providers/ollama.ts) as
94
+ the template. Its structure:
95
+
96
+ - Typed wire DTOs (request/response), never `any`.
97
+ - Small pure mapping helpers: `to_*_messages` (our `Message` -> wire),
98
+ `assistant_to_wire` / `tool_to_wire`, `to_*_tools`, `build_request_body`.
99
+ - The fetch skeleton shared by all clients: resolve auth, `build_endpoint`,
100
+ `do_fetch` (fetch throws -> `network`), `to_http_error` (non-OK ->
101
+ classified kind + `Retry-After`), `read_success_json` (unparseable 2xx ->
102
+ `bad_request`).
103
+ - An error mapper: a `status_to_error_kind` function implementing the
104
+ taxonomy table from [providers](./providers.md#error-taxonomy), plus a
105
+ body-sniffing regex for `overflow` on 400s.
106
+ - A factory export (`create_ollama_provider`) if construction may grow.
107
+
108
+ 2. **The `ProviderConfig` union.** Add the kind string to the `kind` union in
109
+ `src/providers/types.ts` (ProviderConfig), and any kind-specific fields
110
+ next to `think` / `keep_alive` (ollama-only fields are documented as such).
111
+
112
+ 3. **The router kind map.** One branch in `build_provider`
113
+ (src/providers/router.ts):
114
+
115
+ ```ts
116
+ function build_provider(config: ProviderConfig): LLMProvider {
117
+ if (config.kind === "anthropic") {
118
+ return new AnthropicProvider(config);
119
+ }
120
+ if (config.kind === "ollama") {
121
+ return create_ollama_provider(config);
122
+ }
123
+ return new OpenAICompatProvider(config);
124
+ }
125
+ ```
126
+
127
+ (src/providers/router.ts)
128
+
129
+ 4. **The zod config enum.** `kind: z.enum(["openai_compat", "anthropic",
130
+ "ollama"])` in `src/agent/config.ts` must accept the new string or config
131
+ parsing rejects it before the router ever sees it.
132
+
133
+ (Strictly that is four small edits across three files plus the client -
134
+ client file, `ProviderConfig` union, router kind map, zod enum. Ollama
135
+ needed no other changes: failover, retry classification, sessions, and the
136
+ loop all work against the interface, not the client.)
137
+
138
+ **Testing via `fetch_fn`.** Copy the `mock_fetch` helper from
139
+ `test/providers.test.ts` (shown in [providers](./providers.md#testing-pattern)):
140
+ feed your client canned `Response` bodies, assert the captured request's URL,
141
+ headers, and JSON body, then assert the parsed `ChatResult`. Cover at minimum:
142
+ the message/tool wire mapping, one error status per taxonomy kind, and any
143
+ provider quirk you discovered (ollama's tests do exactly this for
144
+ `done_reason` and 200-with-error).
145
+
146
+ ## Add a gateway platform
147
+
148
+ A platform adapter is an implementation of the `PlatformAdapter` contract
149
+ ([`src/gateway/types.ts`](../../src/gateway/types.ts)):
150
+
151
+ ```ts
152
+ export interface PlatformAdapter {
153
+ readonly name: string;
154
+ start(): Promise<void>;
155
+ stop(): Promise<void>;
156
+ }
157
+ ```
158
+
159
+ Inbound flow: the adapter normalizes a platform event into
160
+ `(platform, chat_id, user_id, text)` and calls `run_inbound_message` with the
161
+ shared `InboundHandler`; the handler is the bus's `handle`, which resolves to
162
+ the reply text; the adapter then sends the reply through the platform's send
163
+ API. The bus owns history, serialization, and error sanitization - adapters
164
+ stay thin.
165
+
166
+ Recipe (mirroring [`telegram.ts`](../../src/gateway/telegram.ts), the
167
+ simplest real adapter):
168
+
169
+ 1. **Write `src/gateway/<platform>.ts`** exporting
170
+ `create_<platform>_adapter(params: AdapterParams): PlatformAdapter`.
171
+ Inside, receive messages however the platform delivers them (polling,
172
+ WebSocket, HTTP push), normalize them, then:
173
+
174
+ ```ts
175
+ const reply = await run_inbound_message(
176
+ params.handle_message, "platform-name", chat_id, user_id, text,
177
+ );
178
+ await send_reply(reply);
179
+ ```
180
+
181
+ 2. **Register it in the runner.** Add the platform to `is_known_platform` and
182
+ to the `create_platform_adapter` switch in `src/gateway/runner.ts`. That is
183
+ the only registration point; `build_adapters` handles the rest.
184
+
185
+ 3. **Handle missing credentials with the idle-adapter pattern.** When the
186
+ platform's token env var is absent, return
187
+ `create_idle_adapter("platform", "ENV_VAR not set")` instead of throwing.
188
+ The adapter logs once why it is idle and no-ops on start/stop, so the
189
+ gateway keeps serving the platforms that *do* have credentials -
190
+ `run_gateway` only needs one valid platform.
191
+
192
+ 4. **Reuse the shared helpers**: `open_socket` for WebSocket platforms (the
193
+ structural `RawSocket` type works on both Bun and Node runtimes),
194
+ `sanitize_agent_error` for reply strings on failure, and the whitespace
195
+ splitter `split_text` from `src/gateway/format.ts` for size-capped
196
+ transports (telegram caps at 4096, discord at 2000, twitch at 512).
197
+
198
+ **Testing.** `test/gateway.test.ts` covers the webhook adapter over a real
199
+ `node:http` server (a `on_listening` test hook reports the bound port), the
200
+ bus with a fake agent factory, and pure parsers (`split_text`, Twitch IRC
201
+ line parsing). For a socket platform, inject a fake `RawSocket`
202
+ implementation exercising `onopen`/`onmessage`, or extract a pure parse
203
+ function (like `parse_irc_line`) and unit-test it directly - the twitch
204
+ adapter does exactly that.
205
+
206
+ ## TUI internals
207
+
208
+ The TUI splits cleanly in two:
209
+
210
+ - **`src/tui/state.ts`** - pure logic, zero ink/react imports: the `UiState`
211
+ shape, the `apply_event` reducer, slash-command parsing
212
+ (`parse_command`), and transcript formatters. Unit-tested without a TTY in
213
+ `test/tui.test.ts`.
214
+ - **`src/tui/app.tsx`** (+ `message_view.tsx`, `command_bar.tsx`,
215
+ `status_bar.tsx`) - ink components that hold the only mutable React state,
216
+ feed agent events into `apply_event`, and render blocks.
217
+
218
+ Why: ink components need a TTY and make tests slow and brittle; the reducer
219
+ needs neither. The event -> state table is the whole behavioral contract:
220
+
221
+ | Event | State change |
222
+ | --- | --- |
223
+ | `llm_start` | `phase: "thinking"`, `active_tool: undefined` |
224
+ | `llm_end` | Accumulates `usage` from the result |
225
+ | `tool_call_start` | `phase: "tool"`, `active_tool` set |
226
+ | `tool_call_end` | `phase: "thinking"`, `active_tool` cleared; failed results set `last_error` |
227
+ | `turn_end` | `turns_used: event.turn` |
228
+ | `compress_start` | `compress_count` increments |
229
+ | `budget_exhausted` | `budget_exhausted: true` |
230
+ | `error` | `last_error` set from the error text |
231
+
232
+ `apply_run_result` folds the finished `AgentRunResult` back in (phase back to
233
+ `idle`, session path, abort notice). Design trade-off: state updates are
234
+ event-coarse - there is **no token streaming in the TUI**; users see phase
235
+ changes and completed tool rows rather than streaming text.
236
+
237
+ ## Design constraints
238
+
239
+ These are architecture decisions, not style rules; they are what keeps the
240
+ codebase extensible:
241
+
242
+ - **No recursion.** Tree-shaped work (directory walking in `list_dir`,
243
+ `grep_files`; the retry loop in `failover.ts`) uses explicit stacks, queues,
244
+ and `while` loops. Deep directories and long retry chains cannot overflow
245
+ the stack.
246
+ - **Helpers and hooks stay under 60 lines.** Long functions are split into
247
+ small pure mapping helpers (see the provider clients: every mapping step is
248
+ a named, testable function).
249
+ - **Narrow structural interfaces at every seam.** `ChatFn`, `ToolRunner`,
250
+ `LLMProvider`, `PlatformAdapter`, `Tool` - consumers depend on shapes, not
251
+ concrete classes, so every piece is fake-able in tests.
252
+ - **Zero-dependency core.** Only `zod` (config) and `ink` (TUI) are runtime
253
+ dependencies. Network calls use the global `fetch`; HTTP serving uses
254
+ `node:http`; JSON handling is hand-rolled in `src/util/json.ts`. Nothing to
255
+ audit, nothing to break on upgrade.
256
+ - **Determinism where tests need it.** Backoff jitter is a fixed formula, not
257
+ `Math.random`; time-derived values (session ids, ollama tool-call ids) are
258
+ formatted, not sampled.
259
+
260
+ ## Testing the harness
261
+
262
+ The suite runs with `bun test` (or `bun x vitest run`); it is fully offline -
263
+ every provider call, gateway socket, and HTTP exchange is mocked. Live
264
+ smokes (real OpenAI/Anthropic/Ollama calls, real platform bots) are manual,
265
+ by design.
266
+
267
+ | Test file | Covers |
268
+ | --- | --- |
269
+ | `test/loop.test.ts` | Loop scenarios A/B/C: tool turn then final, budget stop, abort; event ordering; history non-mutation. |
270
+ | `test/agent_config.test.ts` | Zod config parsing, defaults, derived `session_dir`. |
271
+ | `test/compressor.test.ts` | Token estimator, `should_compress` threshold math, summary partitioning. |
272
+ | `test/providers.test.ts` | OpenAI + Anthropic wire mapping, error taxonomy via `fetch_fn` injection. |
273
+ | `test/providers_ollama.test.ts` | Ollama quirks (tool-call presence vs `done_reason`, 200-with-error) and ollama failover. |
274
+ | `test/failover.test.ts` | Error classification, backoff formula, retry loop, router walk, failover ordering. |
275
+ | `test/tools.test.ts` | Guard helpers, registry, executor, and the filesystem builtins. |
276
+ | `test/tools_extra.test.ts` | HTTP tools, `process_list`, `disk_usage`, `env_get`. |
277
+ | `test/gateway.test.ts` | Bus serialization/history caps, webhook server, reply formatting, telegram/twitch splitting and parsing. |
278
+ | `test/tui.test.ts` | The pure TUI state machine: reducer, commands, formatters. |
279
+ | `test/cli_config.test.ts` | Config discovery, loading, flag overrides, template. |
280
+ | `test/e2e.test.ts` | Full runs through the public API (`run_agent`) with mock providers. |
281
+
282
+ When you extend the harness, add the test next to the seam you extended and
283
+ keep the network out of it - see the `fetch_fn` pattern in
284
+ [providers](./providers.md#testing-pattern) and the mock-socket notes above.