@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.
@@ -0,0 +1,333 @@
1
+ import { describe, expect, test, mock, beforeEach, afterEach } from "bun:test";
2
+ import { subscribe } from "../subscribe.ts";
3
+ import type {
4
+ InitialStateEvent,
5
+ StateUpdateEvent,
6
+ ExecutionStatusEvent,
7
+ HeartbeatEvent,
8
+ CustomEvent,
9
+ StateSnapshot,
10
+ } from "../types.ts";
11
+
12
+ // ── Mock EventSource ───────────────────────────────────────────────
13
+
14
+ type EventHandler = (event: MessageEvent) => void;
15
+
16
+ class MockEventSource {
17
+ url: string;
18
+ listeners: Map<string, EventHandler[]> = new Map();
19
+ closed = false;
20
+
21
+ constructor(url: string) {
22
+ this.url = url;
23
+ // Make this instance accessible to tests
24
+ MockEventSource.instances.push(this);
25
+ }
26
+
27
+ addEventListener(event: string, handler: EventHandler) {
28
+ const handlers = this.listeners.get(event) ?? [];
29
+ handlers.push(handler);
30
+ this.listeners.set(event, handlers);
31
+ }
32
+
33
+ close() {
34
+ this.closed = true;
35
+ }
36
+
37
+ // Test helper: emit a named event
38
+ emit(eventType: string, data: unknown) {
39
+ const handlers = this.listeners.get(eventType) ?? [];
40
+ const messageEvent = new MessageEvent(eventType, {
41
+ data: JSON.stringify(data),
42
+ });
43
+ for (const handler of handlers) {
44
+ handler(messageEvent);
45
+ }
46
+ }
47
+
48
+ static instances: MockEventSource[] = [];
49
+ static reset() {
50
+ MockEventSource.instances = [];
51
+ }
52
+ }
53
+
54
+ // Install mock EventSource globally
55
+ const OriginalEventSource = globalThis.EventSource;
56
+
57
+ beforeEach(() => {
58
+ MockEventSource.reset();
59
+ (globalThis as unknown as Record<string, unknown>).EventSource = MockEventSource as unknown as typeof EventSource;
60
+ });
61
+
62
+ afterEach(() => {
63
+ (globalThis as unknown as Record<string, unknown>).EventSource = OriginalEventSource;
64
+ });
65
+
66
+ function lastES(): MockEventSource {
67
+ return MockEventSource.instances[MockEventSource.instances.length - 1];
68
+ }
69
+
70
+ // ── Tests ──────────────────────────────────────────────────────────
71
+
72
+ describe("subscribe()", () => {
73
+ test("connects to the correct URL", () => {
74
+ subscribe("http://localhost:3001", () => {});
75
+ expect(lastES().url).toBe("http://localhost:3001/events");
76
+ });
77
+
78
+ test("uses custom endpoint from options", () => {
79
+ subscribe("http://localhost:3001", () => {}, { endpoint: "/stream/state" });
80
+ expect(lastES().url).toBe("http://localhost:3001/stream/state");
81
+ });
82
+
83
+ describe("state.initial", () => {
84
+ test("sets local state and fires callback with snapshot", () => {
85
+ const snapshots: StateSnapshot[] = [];
86
+ subscribe("http://localhost:3001", (s) => snapshots.push(s));
87
+
88
+ const event: InitialStateEvent = {
89
+ type: "state.initial",
90
+ slices: { agent: { conversation: [] }, execution: { id: null } },
91
+ timestamp: 1000,
92
+ };
93
+ lastES().emit("state.initial", event);
94
+
95
+ expect(snapshots).toHaveLength(1);
96
+ expect(snapshots[0].slices).toEqual({
97
+ agent: { conversation: [] },
98
+ execution: { id: null },
99
+ });
100
+ expect(snapshots[0].event).toEqual(event);
101
+ });
102
+
103
+ test("replaces previous state on new initial event", () => {
104
+ const snapshots: StateSnapshot[] = [];
105
+ subscribe("http://localhost:3001", (s) => snapshots.push(s));
106
+
107
+ // First initial
108
+ lastES().emit("state.initial", {
109
+ type: "state.initial",
110
+ slices: { agent: { conversation: ["a"] }, old: { data: true } },
111
+ timestamp: 1000,
112
+ });
113
+
114
+ // Second initial (reconnect scenario)
115
+ lastES().emit("state.initial", {
116
+ type: "state.initial",
117
+ slices: { agent: { conversation: [] } },
118
+ timestamp: 2000,
119
+ });
120
+
121
+ expect(snapshots).toHaveLength(2);
122
+ // Old slices should be gone
123
+ expect(snapshots[1].slices).toEqual({ agent: { conversation: [] } });
124
+ expect("old" in snapshots[1].slices).toBe(false);
125
+ });
126
+ });
127
+
128
+ describe("state.update with patches", () => {
129
+ test("applies JSON Patches correctly", () => {
130
+ const snapshots: StateSnapshot[] = [];
131
+ subscribe("http://localhost:3001", (s) => snapshots.push(s));
132
+
133
+ // Set initial state
134
+ lastES().emit("state.initial", {
135
+ type: "state.initial",
136
+ slices: { agent: { conversation: [], loopCount: 0 } },
137
+ timestamp: 1000,
138
+ });
139
+
140
+ // Apply patch
141
+ const updateEvent: StateUpdateEvent = {
142
+ type: "state.update",
143
+ slice: "agent",
144
+ patches: [
145
+ { op: "replace", path: "/loopCount", value: 1 },
146
+ { op: "add", path: "/conversation/-", value: "Hello" },
147
+ ],
148
+ timestamp: 2000,
149
+ };
150
+ lastES().emit("state.update", updateEvent);
151
+
152
+ expect(snapshots).toHaveLength(2);
153
+ const agentState = snapshots[1].slices.agent as Record<string, unknown>;
154
+ expect(agentState.loopCount).toBe(1);
155
+ expect(agentState.conversation).toEqual(["Hello"]);
156
+ });
157
+ });
158
+
159
+ describe("state.update with value replacement", () => {
160
+ test("replaces slice with value", () => {
161
+ const snapshots: StateSnapshot[] = [];
162
+ subscribe("http://localhost:3001", (s) => snapshots.push(s));
163
+
164
+ // Set initial state
165
+ lastES().emit("state.initial", {
166
+ type: "state.initial",
167
+ slices: { agent: { conversation: [] } },
168
+ timestamp: 1000,
169
+ });
170
+
171
+ // Full value replacement
172
+ const updateEvent: StateUpdateEvent = {
173
+ type: "state.update",
174
+ slice: "agent",
175
+ value: { conversation: ["msg1"], loopCount: 5 },
176
+ timestamp: 2000,
177
+ };
178
+ lastES().emit("state.update", updateEvent);
179
+
180
+ expect(snapshots).toHaveLength(2);
181
+ expect(snapshots[1].slices.agent).toEqual({ conversation: ["msg1"], loopCount: 5 });
182
+ });
183
+ });
184
+
185
+ describe("multiple updates accumulate state", () => {
186
+ test("state accumulates across multiple events", () => {
187
+ const snapshots: StateSnapshot[] = [];
188
+ subscribe("http://localhost:3001", (s) => snapshots.push(s));
189
+
190
+ // Initial
191
+ lastES().emit("state.initial", {
192
+ type: "state.initial",
193
+ slices: { counter: { value: 0 } },
194
+ timestamp: 1000,
195
+ });
196
+
197
+ // Update 1
198
+ lastES().emit("state.update", {
199
+ type: "state.update",
200
+ slice: "counter",
201
+ patches: [{ op: "replace", path: "/value", value: 1 }],
202
+ timestamp: 2000,
203
+ });
204
+
205
+ // Update 2
206
+ lastES().emit("state.update", {
207
+ type: "state.update",
208
+ slice: "counter",
209
+ patches: [{ op: "replace", path: "/value", value: 2 }],
210
+ timestamp: 3000,
211
+ });
212
+
213
+ // Update 3 — new slice via value
214
+ lastES().emit("state.update", {
215
+ type: "state.update",
216
+ slice: "execution",
217
+ value: { id: "exec-1", state: "running" },
218
+ timestamp: 4000,
219
+ });
220
+
221
+ expect(snapshots).toHaveLength(4);
222
+
223
+ // Final state should have both slices
224
+ const final = snapshots[3].slices;
225
+ expect(final.counter).toEqual({ value: 2 });
226
+ expect(final.execution).toEqual({ id: "exec-1", state: "running" });
227
+ });
228
+ });
229
+
230
+ describe("non-state events", () => {
231
+ test("execution.status fires callback without mutating state", () => {
232
+ const snapshots: StateSnapshot[] = [];
233
+ subscribe("http://localhost:3001", (s) => snapshots.push(s));
234
+
235
+ // Initial
236
+ lastES().emit("state.initial", {
237
+ type: "state.initial",
238
+ slices: { agent: { value: 1 } },
239
+ timestamp: 1000,
240
+ });
241
+
242
+ // Execution status
243
+ const statusEvent: ExecutionStatusEvent = {
244
+ type: "execution.status",
245
+ status: "started",
246
+ executionId: "exec-1",
247
+ timestamp: 2000,
248
+ };
249
+ lastES().emit("execution.status", statusEvent);
250
+
251
+ expect(snapshots).toHaveLength(2);
252
+ // State should be unchanged
253
+ expect(snapshots[1].slices).toEqual({ agent: { value: 1 } });
254
+ expect(snapshots[1].event).toEqual(statusEvent);
255
+ });
256
+
257
+ test("heartbeat fires callback without mutating state", () => {
258
+ const snapshots: StateSnapshot[] = [];
259
+ subscribe("http://localhost:3001", (s) => snapshots.push(s));
260
+
261
+ lastES().emit("state.initial", {
262
+ type: "state.initial",
263
+ slices: {},
264
+ timestamp: 1000,
265
+ });
266
+
267
+ const heartbeat: HeartbeatEvent = { type: "heartbeat", timestamp: 2000 };
268
+ lastES().emit("heartbeat", heartbeat);
269
+
270
+ expect(snapshots).toHaveLength(2);
271
+ expect(snapshots[1].event).toEqual(heartbeat);
272
+ });
273
+
274
+ test("custom-event fires callback without mutating state", () => {
275
+ const snapshots: StateSnapshot[] = [];
276
+ subscribe("http://localhost:3001", (s) => snapshots.push(s));
277
+
278
+ lastES().emit("state.initial", {
279
+ type: "state.initial",
280
+ slices: { x: 1 },
281
+ timestamp: 1000,
282
+ });
283
+
284
+ const custom: CustomEvent = {
285
+ type: "custom-event",
286
+ data: { foo: "bar" },
287
+ timestamp: 2000,
288
+ };
289
+ lastES().emit("custom-event", custom);
290
+
291
+ expect(snapshots).toHaveLength(2);
292
+ expect(snapshots[1].slices).toEqual({ x: 1 });
293
+ expect(snapshots[1].event).toEqual(custom);
294
+ });
295
+ });
296
+
297
+ describe("unsubscribe", () => {
298
+ test("closes EventSource", () => {
299
+ const unsub = subscribe("http://localhost:3001", () => {});
300
+ expect(lastES().closed).toBe(false);
301
+
302
+ unsub();
303
+ expect(lastES().closed).toBe(true);
304
+ });
305
+ });
306
+
307
+ describe("snapshot isolation", () => {
308
+ test("each callback receives a separate slices object", () => {
309
+ const snapshots: StateSnapshot[] = [];
310
+ subscribe("http://localhost:3001", (s) => snapshots.push(s));
311
+
312
+ lastES().emit("state.initial", {
313
+ type: "state.initial",
314
+ slices: { a: 1 },
315
+ timestamp: 1000,
316
+ });
317
+
318
+ lastES().emit("state.update", {
319
+ type: "state.update",
320
+ slice: "a",
321
+ value: 2,
322
+ timestamp: 2000,
323
+ });
324
+
325
+ // Mutating the first snapshot should not affect the second
326
+ expect(snapshots[0].slices.a).toBe(1);
327
+ expect(snapshots[1].slices.a).toBe(2);
328
+
329
+ // They should be different objects
330
+ expect(snapshots[0].slices).not.toBe(snapshots[1].slices);
331
+ });
332
+ });
333
+ });
package/src/client.ts ADDED
@@ -0,0 +1,214 @@
1
+ /**
2
+ * createClient — factory for the typed Gizmo agent SDK
3
+ *
4
+ * One dependency, one createClient(url) call, every operation becomes a
5
+ * thin typed wrapper over the HTTP/SSE API.
6
+ */
7
+
8
+ import { GizmoClientError } from "./errors.ts";
9
+ import { subscribe as sseSubscribe } from "./subscribe.ts";
10
+ import type {
11
+ GizmoManifest,
12
+ InvokeResponse,
13
+ AbortResponse,
14
+ RunSummary,
15
+ RunDetails,
16
+ RunsResponse,
17
+ RunListParams,
18
+ HydrateOptions,
19
+ HydrateResponse,
20
+ HealthResponse,
21
+ DispatchRequest,
22
+ Approval,
23
+ ApprovalsListResponse,
24
+ ApprovalResponse,
25
+ HistoryResponse,
26
+ SubscribeCallback,
27
+ SubscribeOptions,
28
+ Unsubscribe,
29
+ ClientOptions,
30
+ GizmoClient,
31
+ } from "./types.ts";
32
+
33
+ /**
34
+ * Create a typed client for a Gizmo agent.
35
+ *
36
+ * @param baseUrl - The agent's base URL (e.g., "http://localhost:3001")
37
+ * @param options - Optional fetch implementation and default headers
38
+ */
39
+ export function createClient(
40
+ baseUrl: string,
41
+ options?: ClientOptions,
42
+ ): GizmoClient {
43
+ const _fetch = options?.fetch ?? globalThis.fetch;
44
+ const _headers = options?.headers ?? {};
45
+
46
+ // Strip trailing slash from baseUrl
47
+ const base = baseUrl.replace(/\/$/, "");
48
+
49
+ // ── Internal helpers ───────────────────────────────────────────────
50
+
51
+ async function get<T>(path: string): Promise<T> {
52
+ const res = await _fetch(`${base}${path}`, {
53
+ method: "GET",
54
+ headers: { ...headers(), Accept: "application/json" },
55
+ });
56
+ if (!res.ok) {
57
+ const body = await safeJson(res);
58
+ throw new GizmoClientError(res.status, errorMessage(res, body), body);
59
+ }
60
+ return res.json() as Promise<T>;
61
+ }
62
+
63
+ async function post<T>(path: string, body?: unknown): Promise<T> {
64
+ const res = await _fetch(`${base}${path}`, {
65
+ method: "POST",
66
+ headers: { ...headers(), "Content-Type": "application/json", Accept: "application/json" },
67
+ body: body !== undefined ? JSON.stringify(body) : undefined,
68
+ });
69
+ if (!res.ok) {
70
+ const errBody = await safeJson(res);
71
+ throw new GizmoClientError(res.status, errorMessage(res, errBody), errBody);
72
+ }
73
+ return res.json() as Promise<T>;
74
+ }
75
+
76
+ function headers(): Record<string, string> {
77
+ return { ..._headers };
78
+ }
79
+
80
+ // ── Manifest caching ──────────────────────────────────────────────
81
+
82
+ let manifestCache: GizmoManifest | null = null;
83
+
84
+ // ── Runs namespace ────────────────────────────────────────────────
85
+
86
+ const runs = {
87
+ async list(params?: RunListParams): Promise<RunSummary[]> {
88
+ const qs = buildQuery(params as Record<string, unknown> | undefined);
89
+ const res = await get<RunsResponse>(`/runs${qs}`);
90
+ return res.runs;
91
+ },
92
+
93
+ async get(executionId: string): Promise<RunDetails> {
94
+ return get<RunDetails>(`/runs/${encodeURIComponent(executionId)}`);
95
+ },
96
+
97
+ async actions(executionId: string): Promise<RunDetails["actions"]> {
98
+ const run = await runs.get(executionId);
99
+ return run.actions;
100
+ },
101
+
102
+ async cancel(executionId: string): Promise<{ status: string; executionId: string }> {
103
+ return post<{ status: string; executionId: string }>(
104
+ `/runs/${encodeURIComponent(executionId)}/cancel`,
105
+ );
106
+ },
107
+
108
+ async hydrate(executionId: string, opts?: HydrateOptions): Promise<HydrateResponse> {
109
+ return post<HydrateResponse>("/hydrate", { executionId, ...opts });
110
+ },
111
+ };
112
+
113
+ // ── Approvals namespace ───────────────────────────────────────────
114
+
115
+ const approvals = {
116
+ async list(): Promise<Approval[]> {
117
+ const res = await get<ApprovalsListResponse>("/approvals");
118
+ return res.approvals;
119
+ },
120
+
121
+ async history(): Promise<Approval[]> {
122
+ const res = await get<HistoryResponse>("/approvals/history");
123
+ return res.history;
124
+ },
125
+
126
+ async get(id: string): Promise<Approval> {
127
+ const res = await get<ApprovalResponse>(`/approvals/${encodeURIComponent(id)}`);
128
+ return res.approval;
129
+ },
130
+
131
+ async approve(id: string, reason?: string): Promise<Approval> {
132
+ const res = await post<ApprovalResponse>(
133
+ `/approvals/${encodeURIComponent(id)}`,
134
+ { decision: "approve", reason },
135
+ );
136
+ return res.approval;
137
+ },
138
+
139
+ async reject(id: string, reason?: string): Promise<Approval> {
140
+ const res = await post<ApprovalResponse>(
141
+ `/approvals/${encodeURIComponent(id)}`,
142
+ { decision: "reject", reason },
143
+ );
144
+ return res.approval;
145
+ },
146
+ };
147
+
148
+ // ── Public API ────────────────────────────────────────────────────
149
+
150
+ return {
151
+ async discover(): Promise<GizmoManifest> {
152
+ if (manifestCache) return manifestCache;
153
+ manifestCache = await get<GizmoManifest>("/.well-known/manifest.json");
154
+ return manifestCache;
155
+ },
156
+
157
+ async invoke(input: string): Promise<InvokeResponse> {
158
+ return post<InvokeResponse>("/invoke", { input });
159
+ },
160
+
161
+ async abort(): Promise<AbortResponse> {
162
+ return post<AbortResponse>("/abort");
163
+ },
164
+
165
+ async state(slice?: string): Promise<unknown> {
166
+ const path = slice ? `/state/${encodeURIComponent(slice)}` : "/state";
167
+ return get<unknown>(path);
168
+ },
169
+
170
+ subscribe(callback: SubscribeCallback, opts?: SubscribeOptions): Unsubscribe {
171
+ return sseSubscribe(base, callback, opts);
172
+ },
173
+
174
+ async health(): Promise<HealthResponse> {
175
+ return get<HealthResponse>("/health");
176
+ },
177
+
178
+ async dispatch(action: DispatchRequest): Promise<unknown> {
179
+ return post<unknown>("/dispatch", action);
180
+ },
181
+
182
+ runs,
183
+ approvals,
184
+ };
185
+ }
186
+
187
+ // ── Utilities ──────────────────────────────────────────────────────
188
+
189
+ function buildQuery(params?: Record<string, unknown>): string {
190
+ if (!params) return "";
191
+ const entries = Object.entries(params).filter(
192
+ ([, v]) => v !== undefined && v !== null,
193
+ );
194
+ if (entries.length === 0) return "";
195
+ const qs = entries
196
+ .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`)
197
+ .join("&");
198
+ return `?${qs}`;
199
+ }
200
+
201
+ async function safeJson(res: Response): Promise<unknown> {
202
+ try {
203
+ return await res.json();
204
+ } catch {
205
+ return undefined;
206
+ }
207
+ }
208
+
209
+ function errorMessage(res: Response, body: unknown): string {
210
+ if (body && typeof body === "object" && "error" in body) {
211
+ return String((body as { error: string }).error);
212
+ }
213
+ return `HTTP ${res.status}`;
214
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,16 @@
1
+ /**
2
+ * GizmoClientError
3
+ *
4
+ * Thrown when the server returns a non-OK HTTP response.
5
+ */
6
+ export class GizmoClientError extends Error {
7
+ readonly status: number;
8
+ readonly body?: unknown;
9
+
10
+ constructor(status: number, message: string, body?: unknown) {
11
+ super(message);
12
+ this.name = "GizmoClientError";
13
+ this.status = status;
14
+ this.body = body;
15
+ }
16
+ }
package/src/index.ts ADDED
@@ -0,0 +1,63 @@
1
+ /**
2
+ * @gizmo-ai/client
3
+ *
4
+ * Typed SDK for Gizmo agents — discover, invoke, subscribe, and manage runs.
5
+ *
6
+ * Usage:
7
+ * ```typescript
8
+ * import { createClient } from "@gizmo-ai/client";
9
+ *
10
+ * const client = createClient("http://localhost:3001");
11
+ *
12
+ * const manifest = await client.discover();
13
+ * const { executionId } = await client.invoke("Hello!");
14
+ * const unsub = client.subscribe(({ slices, event }) => {
15
+ * console.log(event.type, slices);
16
+ * });
17
+ * ```
18
+ */
19
+
20
+ export { createClient } from "./client.ts";
21
+ export { GizmoClientError } from "./errors.ts";
22
+
23
+ export type {
24
+ // Client
25
+ GizmoClient,
26
+ ClientOptions,
27
+
28
+ // Manifest
29
+ GizmoManifest,
30
+
31
+ // SSE Events
32
+ SSEEvent,
33
+ InitialStateEvent,
34
+ StateUpdateEvent,
35
+ ExecutionStatusEvent,
36
+ HeartbeatEvent,
37
+ CustomEvent,
38
+
39
+ // API Types
40
+ InvokeResponse,
41
+ AbortResponse,
42
+ RunSummary,
43
+ RunDetails,
44
+ RunsResponse,
45
+ RunListParams,
46
+ HydrateOptions,
47
+ HydrateResponse,
48
+ HealthResponse,
49
+ DispatchRequest,
50
+
51
+ // Approvals
52
+ ApprovalStatus,
53
+ Approval,
54
+ ApprovalsListResponse,
55
+ ApprovalResponse,
56
+ HistoryResponse,
57
+
58
+ // Subscribe
59
+ StateSnapshot,
60
+ SubscribeCallback,
61
+ SubscribeOptions,
62
+ Unsubscribe,
63
+ } from "./types.ts";