broapp 0.1.0 → 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.
Files changed (39) hide show
  1. package/README.md +13 -4
  2. package/package.json +10 -2
  3. package/src/ai/host/adapter.ts +109 -0
  4. package/src/ai/host/create-ai.ts +266 -0
  5. package/src/ai/host/fake.ts +230 -0
  6. package/src/ai/host/from-contract.ts +105 -0
  7. package/src/ai/host/index.ts +37 -0
  8. package/src/ai/host/registry.ts +232 -0
  9. package/src/ai/host/run-types.ts +14 -0
  10. package/src/ai/host/run.ts +540 -0
  11. package/src/ai/host/secrets.ts +118 -0
  12. package/src/ai/host/settings.ts +83 -0
  13. package/src/ai/host/threads.ts +366 -0
  14. package/src/ai/host/tool.ts +95 -0
  15. package/src/ai/react/AiChat.tsx +242 -0
  16. package/src/ai/react/AiSettings.tsx +228 -0
  17. package/src/ai/react/ai.css +166 -0
  18. package/src/ai/react/index.tsx +38 -0
  19. package/src/ai/react/provider.tsx +97 -0
  20. package/src/ai/react/use-ai-chat.ts +317 -0
  21. package/src/ai/react/use-ai-models.ts +70 -0
  22. package/src/ai/react/use-ai-settings.ts +105 -0
  23. package/src/ai/shared/contract.ts +247 -0
  24. package/src/ai/shared/index.ts +19 -0
  25. package/src/ai/shared/types.check.ts +71 -0
  26. package/src/ai/shared/types.ts +147 -0
  27. package/src/host/app.ts +183 -36
  28. package/src/host/approvals.ts +115 -0
  29. package/src/host/gate.ts +380 -0
  30. package/src/host/index.ts +25 -0
  31. package/src/host/paths.ts +6 -2
  32. package/src/host/runtime.ts +23 -2
  33. package/src/react/hooks.tsx +37 -3
  34. package/src/react/index.ts +1 -0
  35. package/src/shared/contract.ts +99 -2
  36. package/src/shared/countdown.ts +36 -0
  37. package/src/shared/errors.ts +66 -2
  38. package/src/shared/index.ts +14 -3
  39. package/src/shared/schema.ts +141 -28
package/README.md CHANGED
@@ -22,10 +22,18 @@ Full documentation lives in the
22
22
  | `broapp/client` | The framework-agnostic browser client. |
23
23
  | `broapp/react` | `BroappProvider`, `useOperation`, `useStream`, `useConnection`. |
24
24
  | `broapp/build` | `buildPage`, `buildBinary`, `defineConfig`. |
25
+ | `broapp/ai` | The AI contract and its types. Safe for both sides. |
26
+ | `broapp/ai/host` | `createAi`, `fromContract`, the secret stores, `createFakeAdapter`. Host only. |
27
+ | `broapp/ai/react` | `AiProvider`, `useAiChat`, `useAiSettings`, `useAiModels`, `<AiSettings/>`, `<AiChat/>`. |
25
28
 
26
- Never import `broapp/host` from browser code: it pulls in `node:fs` and
27
- `Bun.spawn`, and a browser bundle that reaches it fails the build — which is the
28
- intended outcome.
29
+ Never import `broapp/host` or `broapp/ai/host` from browser code: they pull in
30
+ `node:fs`, `Bun.spawn` and the AI SDK, and a browser bundle that reaches either
31
+ fails the build — which is the intended outcome.
32
+
33
+ The AI layer is optional. `broapp/ai/host` needs the `ai` peer dependency and
34
+ at least one provider package (`broapp-ai-anthropic`, `broapp-ai-compatible`);
35
+ an application that never imports it carries none of that. See
36
+ [docs/ai.md](https://github.com/praveenvijayan/broapp/blob/main/docs/ai.md).
29
37
 
30
38
  ## The command
31
39
 
@@ -39,7 +47,8 @@ broapp build --all-targets Every supported target
39
47
  ## Requirements
40
48
 
41
49
  Bun 1.2 or newer. Peer dependency on React 18 or newer, and only if you use
42
- `broapp/react`.
50
+ `broapp/react`. Peer dependency on `ai@7.0.93`, and only if you use
51
+ `broapp/ai/host`.
43
52
 
44
53
  ## Licence
45
54
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "broapp",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "description": "Build tooling and runtime for local applications made of a Bun host, a browser UI, and a Brobridge connection between them",
6
6
  "license": "MIT",
@@ -38,6 +38,10 @@
38
38
  "exports": {
39
39
  ".": "./src/shared/index.ts",
40
40
  "./shared": "./src/shared/index.ts",
41
+ "./ai": "./src/ai/shared/index.ts",
42
+ "./ai/host": "./src/ai/host/index.ts",
43
+ "./ai/react": "./src/ai/react/index.tsx",
44
+ "./ai/react/ai.css": "./src/ai/react/ai.css",
41
45
  "./host": "./src/host/index.ts",
42
46
  "./client": "./src/client/index.ts",
43
47
  "./react": "./src/react/index.ts",
@@ -50,11 +54,15 @@
50
54
  "brobridge": "^0.2.1"
51
55
  },
52
56
  "peerDependencies": {
53
- "react": ">=18"
57
+ "react": ">=18",
58
+ "ai": "7.0.93"
54
59
  },
55
60
  "peerDependenciesMeta": {
56
61
  "react": {
57
62
  "optional": true
63
+ },
64
+ "ai": {
65
+ "optional": true
58
66
  }
59
67
  },
60
68
  "devDependencies": {
@@ -0,0 +1,109 @@
1
+ /**
2
+ * What a provider package has to implement.
3
+ *
4
+ * An adapter is the only place in the AI layer that knows a provider exists.
5
+ * It answers four questions — what do you need, what models do you have, does
6
+ * this configuration work, and give me a model — and Broapp's host code is
7
+ * written entirely against those four.
8
+ *
9
+ * Every adapter takes its `fetch` from {@link AdapterConfig} rather than
10
+ * reaching for the global one. That is what makes a test able to prove no
11
+ * request left the machine, and what makes the AI SDK's gateway trap
12
+ * (see `reports/01-spike.md`) impossible to fall into by accident.
13
+ */
14
+ import type { LanguageModel } from 'ai';
15
+
16
+ import { PublicError, publicError } from '../../shared/errors.ts';
17
+ import type { BroappModel } from '../shared/types.ts';
18
+
19
+ /** Everything an adapter needs to reach its provider. */
20
+ export interface AdapterConfig {
21
+ readonly apiKey: string | null;
22
+ readonly baseUrl: string | null;
23
+ /** Injected so tests never touch the network. Defaults to `globalThis.fetch`. */
24
+ readonly fetch: typeof fetch;
25
+ }
26
+
27
+ /** Why an adapter call failed, in terms the layer above can act on. */
28
+ export type AdapterErrorCode = 'auth' | 'network' | 'not_found' | 'rate_limited' | 'provider';
29
+
30
+ /**
31
+ * A failure an adapter reports deliberately.
32
+ *
33
+ * `message` is shown to the user, so it must name the problem in plain words
34
+ * and never include a key, a URL with credentials, or a raw provider response
35
+ * body. Anything an adapter cannot describe safely should be thrown as an
36
+ * ordinary error instead, and the host's error boundary will reduce it.
37
+ */
38
+ export class AdapterError extends Error {
39
+ readonly code: AdapterErrorCode;
40
+
41
+ constructor(code: AdapterErrorCode, message: string, options?: { cause?: unknown }) {
42
+ super(message, options);
43
+ this.name = 'AdapterError';
44
+ this.code = code;
45
+ }
46
+ }
47
+
48
+ /** One provider, as Broapp's host uses it. */
49
+ export interface ProviderAdapter {
50
+ /** Stable id, stored in settings: `'anthropic'`, `'ollama'`, `'fake'`. */
51
+ readonly id: string;
52
+ readonly label: string;
53
+ readonly needs: {
54
+ readonly apiKey: 'required' | 'optional' | 'none';
55
+ readonly baseUrl: 'required' | 'optional' | 'none';
56
+ };
57
+ readonly defaultBaseUrl: string | null;
58
+ /** Whether requests stay on this machine under this config. */
59
+ local(config: AdapterConfig): boolean;
60
+ /** List models. Must reject with {@link AdapterError} on failure. */
61
+ models(config: AdapterConfig, signal: AbortSignal): Promise<BroappModel[]>;
62
+ /** Cheapest possible proof the config works. Must reject with {@link AdapterError}. */
63
+ test(config: AdapterConfig, signal: AbortSignal): Promise<void>;
64
+ /** The AI SDK model. Only `broapp/ai/host` calls this. */
65
+ model(config: AdapterConfig, modelId: string): LanguageModel;
66
+ }
67
+
68
+ /** Hosts that mean "this machine". */
69
+ const LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost', '::1', '[::1]']);
70
+
71
+ /**
72
+ * True when a URL points at this machine.
73
+ *
74
+ * Used to tell the user whether their prompts leave the computer. An address
75
+ * that cannot be parsed is not loopback: an unknown destination is exactly the
76
+ * case where the honest answer is "no".
77
+ */
78
+ export function isLoopbackUrl(url: string): boolean {
79
+ try {
80
+ const parsed = new URL(url);
81
+ // `URL.hostname` strips the brackets from an IPv6 literal, so both spellings
82
+ // are in the set above.
83
+ return LOOPBACK_HOSTS.has(parsed.hostname);
84
+ } catch {
85
+ return false;
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Translate an adapter failure into something the browser may see.
91
+ *
92
+ * Anything that is not an `AdapterError` is rethrown unchanged, so the host's
93
+ * existing boundary logs it with its stack and the browser gets the fixed
94
+ * internal sentence. An adapter that wants a message shown has to say so by
95
+ * choosing an `AdapterError` code.
96
+ */
97
+ export function toPublicError(cause: unknown): PublicError {
98
+ if (!(cause instanceof AdapterError)) throw cause;
99
+ switch (cause.code) {
100
+ case 'auth':
101
+ return publicError.rejected(cause.message);
102
+ case 'not_found':
103
+ return publicError.notFound(cause.message);
104
+ case 'network':
105
+ case 'rate_limited':
106
+ case 'provider':
107
+ return publicError.unavailable(cause.message);
108
+ }
109
+ }
@@ -0,0 +1,266 @@
1
+ /**
2
+ * The AI layer's host runtime.
3
+ *
4
+ * `createAi(...)` builds a second `HostApp` — over Broapp's own AI contract —
5
+ * that an application mounts on the same bridge as its own. Keeping them
6
+ * separate means an application's route table never grows Broapp's routes, and
7
+ * an application that does not call `createAi` carries none of this.
8
+ *
9
+ * Chat arrives in the next layer up; the routes are registered here so that
10
+ * `mount` has an implementation for every route in the contract, which it
11
+ * insists on.
12
+ */
13
+ import type { Bridge } from 'brobridge';
14
+
15
+ // Imported from the host entry point rather than from `host/app.ts` directly:
16
+ // the AI layer is part of the host runtime, and depending on that entry point
17
+ // is what makes a browser bundle of `broapp/ai/host` fail to build. Bun's
18
+ // browser target polyfills `node:fs`, so the file stores alone would not stop
19
+ // this code from being bundled into a page.
20
+ import { createPendingApprovals, createReservedHostApp } from '../../host/index.ts';
21
+ import type { HostApp, HostLogger } from '../../host/app.ts';
22
+ import { publicError } from '../../shared/errors.ts';
23
+ import { aiContract, type AiContract } from '../shared/contract.ts';
24
+ import type { ProviderInfo } from '../shared/types.ts';
25
+
26
+ import { AdapterError, toPublicError, type AdapterConfig, type ProviderAdapter } from './adapter.ts';
27
+ import { createRegistry, type Registry } from './registry.ts';
28
+ import { runChat, type RunDeps } from './run.ts';
29
+ import { createFileSecretStore, createMemorySecretStore } from './secrets.ts';
30
+ import { createSettingsStore } from './settings.ts';
31
+ import { openThreads, type ThreadStore } from './threads.ts';
32
+ import { GUARDED, type AiContextProviders, type AiTool } from './tool.ts';
33
+
34
+ /** What the application is, in the words a model is given. */
35
+ export interface AiAppDescription {
36
+ readonly name: string;
37
+ readonly purpose: string;
38
+ readonly terminology?: readonly string[];
39
+ /**
40
+ * Extra standing instructions, appended verbatim after the purpose.
41
+ *
42
+ * For an assistant whose job needs more than a sentence to describe — the
43
+ * shape of a workspace it edits, a sequence it has to follow, things it may
44
+ * not do. It is host-authored text, not anything a browser or a model
45
+ * supplied, and it goes in front of the documents rather than among them.
46
+ */
47
+ readonly instructions?: string;
48
+ }
49
+
50
+ /** Options for {@link createAi}. */
51
+ export interface CreateAiOptions {
52
+ readonly dataDir: string;
53
+ readonly providers: readonly ProviderAdapter[];
54
+ readonly app: AiAppDescription;
55
+ /** Defaults to `globalThis.fetch`. Tests inject a fake. */
56
+ readonly fetch?: typeof fetch;
57
+ readonly logger?: HostLogger;
58
+ readonly context?: AiContextProviders;
59
+ readonly tools?: Record<string, AiTool>;
60
+ /** Character budget for context documents in one turn. Default 40_000. */
61
+ readonly contextBudgetChars?: number;
62
+ /** Max model steps (tool round trips) per turn. Default 8. */
63
+ readonly maxSteps?: number;
64
+ /** How long a `confirm` tool waits for the user. Default 300_000 ms. */
65
+ readonly confirmTimeoutMs?: number;
66
+ /**
67
+ * Called once when a chat turn ends, however it ends.
68
+ *
69
+ * Autoapp's run store uses it to close the record the gate has been writing
70
+ * steps into: the browser's run identifier is the prefix of every request
71
+ * identifier the turn produced, so this is the one signal that ties the two
72
+ * together. An application that does not record runs leaves it unset.
73
+ */
74
+ readonly onRunEnd?: (
75
+ runId: string,
76
+ status: 'succeeded' | 'failed' | 'cancelled',
77
+ summary: string,
78
+ ) => void;
79
+ }
80
+
81
+ /**
82
+ * What a tool may be called.
83
+ *
84
+ * Dots are allowed so that a contract route can be its own tool name, which is
85
+ * what `fromContract` does. Anything else risks a provider rejecting the whole
86
+ * request over a name the application chose carelessly.
87
+ */
88
+ const TOOL_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_.]*$/;
89
+
90
+ /** The AI layer, ready to mount. */
91
+ export interface Ai {
92
+ mount(bridge: Bridge): void;
93
+ abortAll(reason: string): void;
94
+ /**
95
+ * Release what the layer holds open. Call it from the application's
96
+ * shutdown, beside `abortAll`.
97
+ *
98
+ * Today that is the conversation store: closing it checkpoints the WAL, so
99
+ * what is left on disk is one complete database rather than one that needs
100
+ * its sidecars. Calling it twice is harmless, and an application that never
101
+ * opened a conversation has nothing to close.
102
+ */
103
+ close(): void;
104
+ readonly activeStreams: number;
105
+ /** For tests, and for applications that read settings on the host. */
106
+ readonly registry: Registry;
107
+ }
108
+
109
+ /** How long a provider is given to answer a listing or a connection test. */
110
+ const PROVIDER_TIMEOUT_MS = 20_000;
111
+
112
+ /** Defaults for the run loop, all overridable per application. */
113
+ const DEFAULT_CONTEXT_BUDGET_CHARS = 40_000;
114
+ const DEFAULT_MAX_STEPS = 8;
115
+ const DEFAULT_CONFIRM_TIMEOUT_MS = 300_000;
116
+
117
+ /** Build the AI layer for one application. */
118
+ export function createAi(options: CreateAiOptions): Ai {
119
+ if (options.providers.length === 0) {
120
+ throw new TypeError('createAi needs at least one provider adapter');
121
+ }
122
+ const seen = new Set<string>();
123
+ for (const adapter of options.providers) {
124
+ if (seen.has(adapter.id)) {
125
+ throw new TypeError(`two provider adapters share the id ${JSON.stringify(adapter.id)}`);
126
+ }
127
+ seen.add(adapter.id);
128
+ }
129
+ for (const [name, definition] of Object.entries(options.tools ?? {})) {
130
+ if (!TOOL_NAME_PATTERN.test(name)) {
131
+ throw new TypeError(`tool name ${JSON.stringify(name)} must be letters, digits, "_" or "."`);
132
+ }
133
+ // A tool is host code that a model gets to trigger. Whether it asked
134
+ // anybody first is not visible in its type, so the brand is required
135
+ // rather than hoped for: an application cannot hand a model an ungated
136
+ // capability by forgetting one wrapper.
137
+ if ((definition as { [GUARDED]?: true })[GUARDED] !== true) {
138
+ throw new TypeError(
139
+ `tool ${JSON.stringify(name)} does not pass the gate; build it with guardedTool()`,
140
+ );
141
+ }
142
+ }
143
+
144
+ // Both stores are built once and kept. `remember` chooses between them, and
145
+ // switching has to move a key from one to the other rather than construct a
146
+ // new store and lose what the old one held.
147
+ const registry = createRegistry({
148
+ adapters: options.providers,
149
+ settingsStore: createSettingsStore(options.dataDir),
150
+ fileSecrets: createFileSecretStore(options.dataDir),
151
+ memorySecrets: createMemorySecretStore(),
152
+ fetch: options.fetch ?? globalThis.fetch,
153
+ });
154
+
155
+ const host: HostApp<AiContract> = createReservedHostApp<AiContract>(aiContract, {
156
+ ...(options.logger === undefined ? {} : { logger: options.logger }),
157
+ });
158
+
159
+ host.operation('ai.settingsGet', () => registry.settings());
160
+ host.operation('ai.settingsUpdate', (input) => registry.update(input));
161
+
162
+ host.operation('ai.providersList', () => ({
163
+ providers: options.providers.map((adapter): ProviderInfo => {
164
+ // Deliberately computed without the key: whether requests leave this
165
+ // machine is a property of the address, and the user is entitled to the
166
+ // answer before they have entered anything.
167
+ const config = registry.configFor(adapter);
168
+ return {
169
+ id: adapter.id,
170
+ label: adapter.label,
171
+ local: adapter.local(config),
172
+ needs: { apiKey: adapter.needs.apiKey, baseUrl: adapter.needs.baseUrl },
173
+ defaultBaseUrl: adapter.defaultBaseUrl,
174
+ };
175
+ }),
176
+ }));
177
+
178
+ host.operation('ai.modelsList', async () => {
179
+ const { adapter, config } = await requireConfig();
180
+ try {
181
+ const models = await adapter.models(config, AbortSignal.timeout(PROVIDER_TIMEOUT_MS));
182
+ return { models };
183
+ } catch (cause) {
184
+ throw toPublicError(cause);
185
+ }
186
+ });
187
+
188
+ host.operation('ai.connectionTest', async () => {
189
+ // `resolve()` rather than `currentConfig()`: testing a connection that is
190
+ // missing its key would just ask the provider to reject it, and the layer
191
+ // already knows the answer and can say it in better words.
192
+ const { adapter, config } = await registry.resolve();
193
+ const started = Bun.nanoseconds();
194
+ const elapsed = (): number => Math.round((Bun.nanoseconds() - started) / 1_000_000);
195
+ try {
196
+ await adapter.test(config, AbortSignal.timeout(PROVIDER_TIMEOUT_MS));
197
+ return { ok: true, message: `Connected to ${adapter.label}.`, latencyMs: elapsed() };
198
+ } catch (cause) {
199
+ // A failed connection test is the answer to the question, not a failure
200
+ // of the route: the UI shows the reason next to the button. Anything
201
+ // that is not a deliberate adapter failure is still a fault.
202
+ if (!(cause instanceof AdapterError)) throw cause;
203
+ return { ok: false, message: cause.message, latencyMs: elapsed() };
204
+ }
205
+ });
206
+
207
+ const approvals = createPendingApprovals(options.logger);
208
+ const runDeps: RunDeps = {
209
+ registry,
210
+ app: options.app,
211
+ context: options.context ?? {},
212
+ tools: options.tools ?? {},
213
+ contextBudgetChars: options.contextBudgetChars ?? DEFAULT_CONTEXT_BUDGET_CHARS,
214
+ maxSteps: options.maxSteps ?? DEFAULT_MAX_STEPS,
215
+ confirmTimeoutMs: options.confirmTimeoutMs ?? DEFAULT_CONFIRM_TIMEOUT_MS,
216
+ approvals,
217
+ ...(options.onRunEnd === undefined ? {} : { onRunEnd: options.onRunEnd }),
218
+ logger: options.logger ?? console,
219
+ };
220
+
221
+ host.stream('ai.chat', (params, sink) => runChat(params, sink, runDeps));
222
+ // The wire shape is unchanged: a run and a call name the question, and
223
+ // `accepted` says whether anybody was waiting on it. What changed is where
224
+ // the answer goes — into the same approval table the gate asks.
225
+ host.operation('ai.chatConfirm', ({ runId, callId, approve }) => ({
226
+ accepted:
227
+ approvals.answer({ requestId: `${runId}:${callId}`, approved: approve }) === 'accepted',
228
+ }));
229
+
230
+ // Opened on the first conversation route and not before: an application
231
+ // whose user never opens the panel should not find a database in its data
232
+ // directory, and `createAi` is built unconditionally by every application
233
+ // that offers AI at all.
234
+ let threads: ThreadStore | null = null;
235
+ const threadStore = (): ThreadStore => (threads ??= openThreads(options.dataDir));
236
+
237
+ host.operation('ai.threadsList', () => ({ threads: threadStore().list() }));
238
+ host.operation('ai.threadsCreate', (input) => threadStore().create(input));
239
+ host.operation('ai.threadsGet', ({ id }) => threadStore().get(id));
240
+ host.operation('ai.threadsSave', (input) => threadStore().save(input));
241
+ host.operation('ai.threadsUpdate', (input) => threadStore().update(input));
242
+ host.operation('ai.threadsDelete', ({ id }) => ({ deleted: threadStore().remove(id) }));
243
+ host.operation('ai.threadsClear', () => ({ deleted: threadStore().clear() }));
244
+
245
+ /** The current provider config, or the "not set up" error. */
246
+ async function requireConfig(): Promise<{ adapter: ProviderAdapter; config: AdapterConfig }> {
247
+ const current = await registry.currentConfig();
248
+ if (current === null) {
249
+ throw publicError.unavailable('AI is not set up yet. Open Settings to choose a provider.');
250
+ }
251
+ return current;
252
+ }
253
+
254
+ return {
255
+ mount: (bridge: Bridge) => host.mount(bridge),
256
+ abortAll: (reason: string) => host.abortAll(reason),
257
+ close: () => {
258
+ threads?.close();
259
+ threads = null;
260
+ },
261
+ get activeStreams() {
262
+ return host.activeStreams;
263
+ },
264
+ registry,
265
+ };
266
+ }
@@ -0,0 +1,230 @@
1
+ /**
2
+ * An adapter that answers without a provider.
3
+ *
4
+ * It exists for Broapp's own tests and for the tests of applications built on
5
+ * Broapp: the AI layer is a lot of behaviour that has nothing to do with any
6
+ * particular vendor, and none of it should need a key or a network to test.
7
+ *
8
+ * The model it returns is the AI SDK's own `MockLanguageModelV4` from
9
+ * `ai/test`, so `streamText` runs its real loop — tool calls, steps, finish
10
+ * reasons and all — over a scripted set of chunks.
11
+ */
12
+ import { simulateReadableStream } from 'ai';
13
+ import type { LanguageModel } from 'ai';
14
+ import { MockLanguageModelV4 } from 'ai/test';
15
+
16
+ import type { BroappModel } from '../shared/types.ts';
17
+
18
+ import { AdapterError, type AdapterConfig, type ProviderAdapter } from './adapter.ts';
19
+
20
+ /**
21
+ * The provider-level stream types, taken from the mock rather than imported.
22
+ *
23
+ * `@ai-sdk/provider` is not a dependency of `broapp` — it arrives under `ai` —
24
+ * so the shapes are read off the class that has to accept them. A change in
25
+ * the AI SDK then shows up here as a type error rather than a wrong chunk.
26
+ */
27
+ type StreamResult = Awaited<ReturnType<MockLanguageModelV4['doStream']>>;
28
+ type StreamPart = StreamResult['stream'] extends ReadableStream<infer P> ? P : never;
29
+ type CallOptions = Parameters<MockLanguageModelV4['doStream']>[0];
30
+
31
+ /** One scripted model step. */
32
+ export type FakeStep =
33
+ | { readonly kind: 'text'; readonly chunks: readonly string[] }
34
+ /** `then` runs after the tool result comes back. */
35
+ | { readonly kind: 'tool'; readonly name: string; readonly input: unknown; readonly then: readonly FakeStep[] };
36
+
37
+ /** How to shape a fake adapter for one test. */
38
+ export interface FakeAdapterOptions {
39
+ /** Default `'fake'`. */
40
+ readonly id?: string;
41
+ /** Default: one model, `fake-1`. */
42
+ readonly models?: readonly BroappModel[];
43
+ /** Default `false`. */
44
+ readonly needsKey?: boolean;
45
+ /** When set, `test()` rejects with it. */
46
+ readonly failTestWith?: AdapterError;
47
+ /** What the model says, step by step. Default: one text step. */
48
+ readonly script?: readonly FakeStep[];
49
+ /** Delay between chunks, so a cancel test can catch a stream mid-flight. */
50
+ readonly chunkDelayMs?: number;
51
+ /**
52
+ * Whether the default model says it can read images. Default `false`.
53
+ *
54
+ * The default stays `false` so a test that does not mention images keeps
55
+ * proving that a turn with images is refused by a model that cannot see.
56
+ */
57
+ readonly vision?: boolean;
58
+ }
59
+
60
+ /** A fake adapter, plus what the test wants to know about it afterwards. */
61
+ export interface FakeAdapter extends ProviderAdapter {
62
+ /** Every prompt the model was given, in order. */
63
+ readonly calls: readonly unknown[];
64
+ /** How many times `model()` was called. Proves no string model id was used. */
65
+ readonly modelCalls: number;
66
+ /** How many times a call was abandoned because its signal aborted. */
67
+ readonly aborted: number;
68
+ }
69
+
70
+ function defaultModel(providerId: string, vision: boolean): BroappModel {
71
+ return {
72
+ provider: providerId,
73
+ modelId: 'fake-1',
74
+ label: 'Fake 1',
75
+ capabilities: { tools: true, vision, structuredOutput: true },
76
+ };
77
+ }
78
+
79
+ /** The usage every fake step reports. Small and constant, so tests can assert it. */
80
+ const USAGE = {
81
+ inputTokens: { total: 11, noCache: 11, cacheRead: 0, cacheWrite: 0 },
82
+ outputTokens: { total: 7, text: 7, reasoning: 0 },
83
+ } as const;
84
+
85
+ /**
86
+ * Flatten a script into the sequence of steps `streamText` will ask for.
87
+ *
88
+ * `doStream` is called once per step of the agent loop, so a `tool` step's
89
+ * `then` steps are simply the ones that follow it. Nesting is how a test
90
+ * writes "and after the tool comes back, say this".
91
+ */
92
+ function flatten(script: readonly FakeStep[]): FakeStep[] {
93
+ const out: FakeStep[] = [];
94
+ for (const step of script) {
95
+ out.push(step);
96
+ if (step.kind === 'tool') out.push(...flatten(step.then));
97
+ }
98
+ return out;
99
+ }
100
+
101
+ /** The chunks for one step. */
102
+ function chunksFor(step: FakeStep, callIndex: number): StreamPart[] {
103
+ if (step.kind === 'text') {
104
+ const parts: StreamPart[] = [{ type: 'text-start', id: String(callIndex) }];
105
+ for (const chunk of step.chunks) {
106
+ parts.push({ type: 'text-delta', id: String(callIndex), delta: chunk });
107
+ }
108
+ parts.push({ type: 'text-end', id: String(callIndex) });
109
+ parts.push({
110
+ type: 'finish',
111
+ finishReason: { unified: 'stop', raw: 'stop' },
112
+ usage: USAGE,
113
+ });
114
+ return parts;
115
+ }
116
+ // A tool call arrives whole: this mock has no reason to dribble the input
117
+ // out in deltas, and the loop under test does not care if it did.
118
+ const toolCallId = `call-${String(callIndex)}`;
119
+ return [
120
+ {
121
+ type: 'tool-call',
122
+ toolCallId,
123
+ toolName: step.name,
124
+ input: JSON.stringify(step.input),
125
+ },
126
+ {
127
+ type: 'finish',
128
+ finishReason: { unified: 'tool-calls', raw: 'tool_calls' },
129
+ usage: USAGE,
130
+ },
131
+ ];
132
+ }
133
+
134
+ /** Build an adapter that needs no provider. */
135
+ export function createFakeAdapter(options: FakeAdapterOptions = {}): FakeAdapter {
136
+ const id = options.id ?? 'fake';
137
+ const models = options.models ?? [defaultModel(id, options.vision === true)];
138
+ const script = options.script ?? [{ kind: 'text', chunks: ['fake reply'] } as const];
139
+ const steps = flatten(script);
140
+ const delay = options.chunkDelayMs ?? 0;
141
+
142
+ const calls: unknown[] = [];
143
+ let callIndex = 0;
144
+ let modelCalls = 0;
145
+ let aborted = 0;
146
+
147
+ const adapter: FakeAdapter = {
148
+ id,
149
+ label: 'Fake provider',
150
+ needs: { apiKey: options.needsKey === true ? 'required' : 'none', baseUrl: 'none' },
151
+ defaultBaseUrl: null,
152
+ // Nothing leaves the process, so this is true whatever the configuration.
153
+ local: () => true,
154
+
155
+ models: (_config: AdapterConfig, _signal: AbortSignal) => Promise.resolve([...models]),
156
+
157
+ test: (_config: AdapterConfig, _signal: AbortSignal) =>
158
+ options.failTestWith === undefined ? Promise.resolve() : Promise.reject(options.failTestWith),
159
+
160
+ get calls() {
161
+ return calls;
162
+ },
163
+ get modelCalls() {
164
+ return modelCalls;
165
+ },
166
+ get aborted() {
167
+ return aborted;
168
+ },
169
+
170
+ model(_config: AdapterConfig, modelId: string): LanguageModel {
171
+ modelCalls += 1;
172
+ return new MockLanguageModelV4({
173
+ provider: id,
174
+ modelId,
175
+ doStream: (callOptions: CallOptions): Promise<StreamResult> => {
176
+ calls.push(callOptions.prompt);
177
+ if (callOptions.abortSignal?.aborted === true) {
178
+ aborted += 1;
179
+ // The name is what `streamText` checks for, so a cancelled turn
180
+ // looks like a cancelled turn rather than a provider failure.
181
+ const error = new Error('aborted');
182
+ error.name = 'AbortError';
183
+ return Promise.reject(error);
184
+ }
185
+ const step = steps[callIndex] ?? { kind: 'text' as const, chunks: [''] };
186
+ const parts = chunksFor(step, callIndex);
187
+ callIndex += 1;
188
+ const stream = simulateReadableStream<StreamPart>({
189
+ initialDelayInMs: delay === 0 ? 0 : delay,
190
+ chunkDelayInMs: delay,
191
+ chunks: parts,
192
+ });
193
+ // The signal has to reach the stream too: a cancel that arrives
194
+ // while chunks are still being delivered must stop them, not wait
195
+ // for the script to run out.
196
+ return Promise.resolve({ stream: withAbort(stream, callOptions.abortSignal, () => {
197
+ aborted += 1;
198
+ }) });
199
+ },
200
+ });
201
+ },
202
+ };
203
+ return adapter;
204
+ }
205
+
206
+ /** Wrap a stream so an aborted signal ends it instead of letting it run on. */
207
+ function withAbort<T>(
208
+ stream: ReadableStream<T>,
209
+ signal: AbortSignal | undefined,
210
+ onAbort: () => void,
211
+ ): ReadableStream<T> {
212
+ if (signal === undefined) return stream;
213
+ const reader = stream.getReader();
214
+ return new ReadableStream<T>({
215
+ async pull(controller) {
216
+ if (signal.aborted) {
217
+ onAbort();
218
+ await reader.cancel().catch(() => undefined);
219
+ controller.close();
220
+ return;
221
+ }
222
+ const { done, value } = await reader.read();
223
+ if (done) controller.close();
224
+ else controller.enqueue(value);
225
+ },
226
+ async cancel(reason) {
227
+ await reader.cancel(reason).catch(() => undefined);
228
+ },
229
+ });
230
+ }