@gizmo-ai/client 0.2.2
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.
- package/dist/__tests__/client.test.d.ts +2 -0
- package/dist/__tests__/client.test.d.ts.map +1 -0
- package/dist/__tests__/subscribe.test.d.ts +2 -0
- package/dist/__tests__/subscribe.test.d.ts.map +1 -0
- package/dist/client.d.ts +15 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/errors.d.ts +11 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +835 -0
- package/dist/subscribe.d.ts +18 -0
- package/dist/subscribe.d.ts.map +1 -0
- package/dist/types.d.ts +218 -0
- package/dist/types.d.ts.map +1 -0
- package/package.json +33 -0
- package/src/__tests__/client.test.ts +412 -0
- package/src/__tests__/subscribe.test.ts +333 -0
- package/src/client.ts +214 -0
- package/src/errors.ts +16 -0
- package/src/index.ts +63 -0
- package/src/subscribe.ts +87 -0
- package/src/types.ts +271 -0
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
import { describe, expect, test, mock } from "bun:test";
|
|
2
|
+
import { createClient } from "../client.ts";
|
|
3
|
+
import { GizmoClientError } from "../errors.ts";
|
|
4
|
+
import type { GizmoManifest, InvokeResponse, RunsResponse, RunDetails, RunSummary, HydrateResponse } from "../types.ts";
|
|
5
|
+
|
|
6
|
+
// ── Mock fetch helper ──────────────────────────────────────────────
|
|
7
|
+
|
|
8
|
+
interface TrackedFetch {
|
|
9
|
+
fetch: typeof globalThis.fetch;
|
|
10
|
+
calls: Array<{ url: string; init?: RequestInit }>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function mockFetch(responses: Record<string, { status?: number; body: unknown }>): TrackedFetch {
|
|
14
|
+
const calls: TrackedFetch["calls"] = [];
|
|
15
|
+
const fn = async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
16
|
+
const url = typeof input === "string" ? input : input.toString();
|
|
17
|
+
const method = init?.method ?? "GET";
|
|
18
|
+
const key = `${method} ${url}`;
|
|
19
|
+
|
|
20
|
+
calls.push({ url, init });
|
|
21
|
+
|
|
22
|
+
// Find matching response — try exact match first
|
|
23
|
+
const match = responses[key];
|
|
24
|
+
|
|
25
|
+
if (!match) {
|
|
26
|
+
return new Response(JSON.stringify({ error: "Not found" }), {
|
|
27
|
+
status: 404,
|
|
28
|
+
headers: { "Content-Type": "application/json" },
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return new Response(JSON.stringify(match.body), {
|
|
33
|
+
status: match.status ?? 200,
|
|
34
|
+
headers: { "Content-Type": "application/json" },
|
|
35
|
+
});
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
return { fetch: fn as typeof globalThis.fetch, calls };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ── Fixtures ───────────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
const MANIFEST: GizmoManifest = {
|
|
44
|
+
manifestVersion: "1.0.0",
|
|
45
|
+
identity: { agentId: "test-agent", name: "Test Agent", version: "1.0.0" },
|
|
46
|
+
endpoints: {
|
|
47
|
+
invoke: "/invoke",
|
|
48
|
+
state: "/state",
|
|
49
|
+
runs: "/runs",
|
|
50
|
+
streams: { state: "/stream/state", actions: "/stream/actions", events: "/events" },
|
|
51
|
+
},
|
|
52
|
+
state: { slices: ["agent", "execution"], schemaVersion: "1.0.0" },
|
|
53
|
+
capabilities: {},
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
// ── Tests ──────────────────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
describe("createClient", () => {
|
|
59
|
+
describe("discover()", () => {
|
|
60
|
+
test("fetches manifest and caches it", async () => {
|
|
61
|
+
const { fetch, calls } = mockFetch({
|
|
62
|
+
"GET http://localhost:3001/.well-known/manifest.json": { body: MANIFEST },
|
|
63
|
+
});
|
|
64
|
+
const client = createClient("http://localhost:3001", { fetch });
|
|
65
|
+
|
|
66
|
+
const m1 = await client.discover();
|
|
67
|
+
const m2 = await client.discover();
|
|
68
|
+
|
|
69
|
+
expect(m1).toEqual(MANIFEST);
|
|
70
|
+
expect(m2).toEqual(MANIFEST);
|
|
71
|
+
// fetch should only be called once due to caching
|
|
72
|
+
expect(calls).toHaveLength(1);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe("invoke()", () => {
|
|
77
|
+
test("sends correct body and returns response", async () => {
|
|
78
|
+
const response: InvokeResponse = { executionId: "exec-1", turnId: "turn-1" };
|
|
79
|
+
const { fetch, calls } = mockFetch({
|
|
80
|
+
"POST http://localhost:3001/invoke": { status: 200, body: response },
|
|
81
|
+
});
|
|
82
|
+
const client = createClient("http://localhost:3001", { fetch });
|
|
83
|
+
|
|
84
|
+
const result = await client.invoke("Hello agent");
|
|
85
|
+
expect(result).toEqual(response);
|
|
86
|
+
|
|
87
|
+
// Verify the body was sent correctly
|
|
88
|
+
expect(JSON.parse(calls[0].init!.body as string)).toEqual({ input: "Hello agent" });
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe("abort()", () => {
|
|
93
|
+
test("posts to /abort", async () => {
|
|
94
|
+
const { fetch } = mockFetch({
|
|
95
|
+
"POST http://localhost:3001/abort": { body: { status: "aborted", executionId: "exec-1" } },
|
|
96
|
+
});
|
|
97
|
+
const client = createClient("http://localhost:3001", { fetch });
|
|
98
|
+
|
|
99
|
+
const result = await client.abort();
|
|
100
|
+
expect(result.status).toBe("aborted");
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe("state()", () => {
|
|
105
|
+
test("fetches full state without slice arg", async () => {
|
|
106
|
+
const state = { agent: { conversation: [] }, execution: { id: null } };
|
|
107
|
+
const { fetch } = mockFetch({
|
|
108
|
+
"GET http://localhost:3001/state": { body: state },
|
|
109
|
+
});
|
|
110
|
+
const client = createClient("http://localhost:3001", { fetch });
|
|
111
|
+
|
|
112
|
+
const result = await client.state();
|
|
113
|
+
expect(result).toEqual(state);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("fetches specific slice when provided", async () => {
|
|
117
|
+
const agentState = { conversation: [], loopCount: 0 };
|
|
118
|
+
const { fetch } = mockFetch({
|
|
119
|
+
"GET http://localhost:3001/state/agent": { body: agentState },
|
|
120
|
+
});
|
|
121
|
+
const client = createClient("http://localhost:3001", { fetch });
|
|
122
|
+
|
|
123
|
+
const result = await client.state("agent");
|
|
124
|
+
expect(result).toEqual(agentState);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
describe("health()", () => {
|
|
129
|
+
test("fetches health endpoint", async () => {
|
|
130
|
+
const { fetch } = mockFetch({
|
|
131
|
+
"GET http://localhost:3001/health": { body: { status: "ok" } },
|
|
132
|
+
});
|
|
133
|
+
const client = createClient("http://localhost:3001", { fetch });
|
|
134
|
+
|
|
135
|
+
const result = await client.health();
|
|
136
|
+
expect(result).toEqual({ status: "ok" });
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
describe("dispatch()", () => {
|
|
141
|
+
test("posts action to /dispatch", async () => {
|
|
142
|
+
const { fetch, calls } = mockFetch({
|
|
143
|
+
"POST http://localhost:3001/dispatch": { body: { ok: true } },
|
|
144
|
+
});
|
|
145
|
+
const client = createClient("http://localhost:3001", { fetch });
|
|
146
|
+
|
|
147
|
+
await client.dispatch({ type: "CUSTOM_ACTION", payload: { value: 42 } });
|
|
148
|
+
|
|
149
|
+
expect(JSON.parse(calls[0].init!.body as string)).toEqual({
|
|
150
|
+
type: "CUSTOM_ACTION",
|
|
151
|
+
payload: { value: 42 },
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
describe("runs", () => {
|
|
157
|
+
test("list() builds query params correctly", async () => {
|
|
158
|
+
const { fetch } = mockFetch({
|
|
159
|
+
"GET http://localhost:3001/runs?status=completed&limit=5": {
|
|
160
|
+
body: { runs: [] } satisfies RunsResponse,
|
|
161
|
+
},
|
|
162
|
+
});
|
|
163
|
+
const client = createClient("http://localhost:3001", { fetch });
|
|
164
|
+
|
|
165
|
+
const result = await client.runs.list({ status: "completed", limit: 5 });
|
|
166
|
+
expect(result).toEqual([]);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("list() without params calls /runs", async () => {
|
|
170
|
+
const runs: RunSummary[] = [
|
|
171
|
+
{ executionId: "exec-1", startTime: 1000, status: "completed", actionCount: 5 },
|
|
172
|
+
];
|
|
173
|
+
const { fetch } = mockFetch({
|
|
174
|
+
"GET http://localhost:3001/runs": { body: { runs } },
|
|
175
|
+
});
|
|
176
|
+
const client = createClient("http://localhost:3001", { fetch });
|
|
177
|
+
|
|
178
|
+
const result = await client.runs.list();
|
|
179
|
+
expect(result).toEqual(runs);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test("get() fetches run details", async () => {
|
|
183
|
+
const run: RunDetails = {
|
|
184
|
+
executionId: "exec-1",
|
|
185
|
+
startTime: 1000,
|
|
186
|
+
status: "completed",
|
|
187
|
+
actionCount: 2,
|
|
188
|
+
actions: [{ type: "A" }, { type: "B" }],
|
|
189
|
+
};
|
|
190
|
+
const { fetch } = mockFetch({
|
|
191
|
+
"GET http://localhost:3001/runs/exec-1": { body: run },
|
|
192
|
+
});
|
|
193
|
+
const client = createClient("http://localhost:3001", { fetch });
|
|
194
|
+
|
|
195
|
+
const result = await client.runs.get("exec-1");
|
|
196
|
+
expect(result).toEqual(run);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test("actions() returns just the actions array", async () => {
|
|
200
|
+
const actions = [{ type: "A" }, { type: "B" }];
|
|
201
|
+
const run: RunDetails = {
|
|
202
|
+
executionId: "exec-1",
|
|
203
|
+
startTime: 1000,
|
|
204
|
+
status: "completed",
|
|
205
|
+
actionCount: 2,
|
|
206
|
+
actions,
|
|
207
|
+
};
|
|
208
|
+
const { fetch } = mockFetch({
|
|
209
|
+
"GET http://localhost:3001/runs/exec-1": { body: run },
|
|
210
|
+
});
|
|
211
|
+
const client = createClient("http://localhost:3001", { fetch });
|
|
212
|
+
|
|
213
|
+
const result = await client.runs.actions("exec-1");
|
|
214
|
+
expect(result).toEqual(actions);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test("cancel() posts to /runs/:id/cancel", async () => {
|
|
218
|
+
const { fetch } = mockFetch({
|
|
219
|
+
"POST http://localhost:3001/runs/exec-1/cancel": {
|
|
220
|
+
body: { status: "cancelled", executionId: "exec-1" },
|
|
221
|
+
},
|
|
222
|
+
});
|
|
223
|
+
const client = createClient("http://localhost:3001", { fetch });
|
|
224
|
+
|
|
225
|
+
const result = await client.runs.cancel("exec-1");
|
|
226
|
+
expect(result.status).toBe("cancelled");
|
|
227
|
+
expect(result.executionId).toBe("exec-1");
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test("hydrate() posts to /hydrate with executionId in body", async () => {
|
|
231
|
+
const hydrateRes: HydrateResponse = {
|
|
232
|
+
status: "hydrated",
|
|
233
|
+
actionCount: 10,
|
|
234
|
+
state: { conversationLength: 5, loopCount: 2 },
|
|
235
|
+
};
|
|
236
|
+
const { fetch, calls } = mockFetch({
|
|
237
|
+
"POST http://localhost:3001/hydrate": { body: hydrateRes },
|
|
238
|
+
});
|
|
239
|
+
const client = createClient("http://localhost:3001", { fetch });
|
|
240
|
+
|
|
241
|
+
const result = await client.runs.hydrate("exec-1", { upToTurn: 3 });
|
|
242
|
+
expect(result).toEqual(hydrateRes);
|
|
243
|
+
|
|
244
|
+
// Verify executionId is in the body
|
|
245
|
+
expect(JSON.parse(calls[0].init!.body as string)).toEqual({
|
|
246
|
+
executionId: "exec-1",
|
|
247
|
+
upToTurn: 3,
|
|
248
|
+
});
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
describe("approvals", () => {
|
|
253
|
+
const approval = {
|
|
254
|
+
id: "apr-1",
|
|
255
|
+
executionId: "exec-1",
|
|
256
|
+
toolCall: { name: "bash", args: { cmd: "ls" } },
|
|
257
|
+
status: "pending" as const,
|
|
258
|
+
requestedAt: 1000,
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
test("list() returns approvals array", async () => {
|
|
262
|
+
const { fetch } = mockFetch({
|
|
263
|
+
"GET http://localhost:3001/approvals": { body: { approvals: [approval] } },
|
|
264
|
+
});
|
|
265
|
+
const client = createClient("http://localhost:3001", { fetch });
|
|
266
|
+
|
|
267
|
+
const result = await client.approvals.list();
|
|
268
|
+
expect(result).toEqual([approval]);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
test("history() returns history array", async () => {
|
|
272
|
+
const decided = { ...approval, status: "approved" as const, decidedAt: 2000 };
|
|
273
|
+
const { fetch } = mockFetch({
|
|
274
|
+
"GET http://localhost:3001/approvals/history": { body: { history: [decided] } },
|
|
275
|
+
});
|
|
276
|
+
const client = createClient("http://localhost:3001", { fetch });
|
|
277
|
+
|
|
278
|
+
const result = await client.approvals.history();
|
|
279
|
+
expect(result).toEqual([decided]);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
test("get() returns single approval", async () => {
|
|
283
|
+
const { fetch } = mockFetch({
|
|
284
|
+
"GET http://localhost:3001/approvals/apr-1": { body: { approval } },
|
|
285
|
+
});
|
|
286
|
+
const client = createClient("http://localhost:3001", { fetch });
|
|
287
|
+
|
|
288
|
+
const result = await client.approvals.get("apr-1");
|
|
289
|
+
expect(result).toEqual(approval);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
test("approve() sends correct decision", async () => {
|
|
293
|
+
const decided = { ...approval, status: "approved" as const, decidedAt: 2000 };
|
|
294
|
+
const { fetch, calls } = mockFetch({
|
|
295
|
+
"POST http://localhost:3001/approvals/apr-1": { body: { approval: decided } },
|
|
296
|
+
});
|
|
297
|
+
const client = createClient("http://localhost:3001", { fetch });
|
|
298
|
+
|
|
299
|
+
const result = await client.approvals.approve("apr-1", "Looks safe");
|
|
300
|
+
expect(result.status).toBe("approved");
|
|
301
|
+
|
|
302
|
+
expect(JSON.parse(calls[0].init!.body as string)).toEqual({
|
|
303
|
+
decision: "approve",
|
|
304
|
+
reason: "Looks safe",
|
|
305
|
+
});
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
test("reject() sends correct decision", async () => {
|
|
309
|
+
const decided = { ...approval, status: "rejected" as const, decidedAt: 2000 };
|
|
310
|
+
const { fetch, calls } = mockFetch({
|
|
311
|
+
"POST http://localhost:3001/approvals/apr-1": { body: { approval: decided } },
|
|
312
|
+
});
|
|
313
|
+
const client = createClient("http://localhost:3001", { fetch });
|
|
314
|
+
|
|
315
|
+
const result = await client.approvals.reject("apr-1", "Too dangerous");
|
|
316
|
+
expect(result.status).toBe("rejected");
|
|
317
|
+
|
|
318
|
+
expect(JSON.parse(calls[0].init!.body as string)).toEqual({
|
|
319
|
+
decision: "reject",
|
|
320
|
+
reason: "Too dangerous",
|
|
321
|
+
});
|
|
322
|
+
});
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
describe("error handling", () => {
|
|
326
|
+
test("HTTP errors throw GizmoClientError", async () => {
|
|
327
|
+
const { fetch } = mockFetch({
|
|
328
|
+
"GET http://localhost:3001/health": {
|
|
329
|
+
status: 500,
|
|
330
|
+
body: { error: "Internal server error" },
|
|
331
|
+
},
|
|
332
|
+
});
|
|
333
|
+
const client = createClient("http://localhost:3001", { fetch });
|
|
334
|
+
|
|
335
|
+
try {
|
|
336
|
+
await client.health();
|
|
337
|
+
expect.unreachable("Should have thrown");
|
|
338
|
+
} catch (err) {
|
|
339
|
+
expect(err).toBeInstanceOf(GizmoClientError);
|
|
340
|
+
const e = err as GizmoClientError;
|
|
341
|
+
expect(e.status).toBe(500);
|
|
342
|
+
expect(e.message).toBe("Internal server error");
|
|
343
|
+
expect(e.body).toEqual({ error: "Internal server error" });
|
|
344
|
+
}
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
test("404 errors include status", async () => {
|
|
348
|
+
const { fetch } = mockFetch({
|
|
349
|
+
"GET http://localhost:3001/runs/missing": {
|
|
350
|
+
status: 404,
|
|
351
|
+
body: { error: "Run \"missing\" not found" },
|
|
352
|
+
},
|
|
353
|
+
});
|
|
354
|
+
const client = createClient("http://localhost:3001", { fetch });
|
|
355
|
+
|
|
356
|
+
try {
|
|
357
|
+
await client.runs.get("missing");
|
|
358
|
+
expect.unreachable("Should have thrown");
|
|
359
|
+
} catch (err) {
|
|
360
|
+
const e = err as GizmoClientError;
|
|
361
|
+
expect(e.status).toBe(404);
|
|
362
|
+
}
|
|
363
|
+
});
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
describe("custom headers", () => {
|
|
367
|
+
test("headers are threaded through all requests", async () => {
|
|
368
|
+
const { fetch, calls } = mockFetch({
|
|
369
|
+
"GET http://localhost:3001/health": { body: { status: "ok" } },
|
|
370
|
+
});
|
|
371
|
+
const client = createClient("http://localhost:3001", {
|
|
372
|
+
fetch,
|
|
373
|
+
headers: { Authorization: "Bearer token-123" },
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
await client.health();
|
|
377
|
+
|
|
378
|
+
const reqHeaders = calls[0].init!.headers as Record<string, string>;
|
|
379
|
+
expect(reqHeaders.Authorization).toBe("Bearer token-123");
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
test("custom headers are present on POST requests", async () => {
|
|
383
|
+
const { fetch, calls } = mockFetch({
|
|
384
|
+
"POST http://localhost:3001/invoke": {
|
|
385
|
+
body: { executionId: "e", turnId: "t" },
|
|
386
|
+
},
|
|
387
|
+
});
|
|
388
|
+
const client = createClient("http://localhost:3001", {
|
|
389
|
+
fetch,
|
|
390
|
+
headers: { "X-Api-Key": "key-456" },
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
await client.invoke("test");
|
|
394
|
+
|
|
395
|
+
const reqHeaders = calls[0].init!.headers as Record<string, string>;
|
|
396
|
+
expect(reqHeaders["X-Api-Key"]).toBe("key-456");
|
|
397
|
+
});
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
describe("trailing slash handling", () => {
|
|
401
|
+
test("strips trailing slash from baseUrl", async () => {
|
|
402
|
+
const { fetch, calls } = mockFetch({
|
|
403
|
+
"GET http://localhost:3001/health": { body: { status: "ok" } },
|
|
404
|
+
});
|
|
405
|
+
const client = createClient("http://localhost:3001/", { fetch });
|
|
406
|
+
|
|
407
|
+
await client.health();
|
|
408
|
+
|
|
409
|
+
expect(calls[0].url).toBe("http://localhost:3001/health");
|
|
410
|
+
});
|
|
411
|
+
});
|
|
412
|
+
});
|