@stigmer/react 1.0.3 → 1.0.4

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 (57) hide show
  1. package/agent/__tests__/useAgent.test.d.ts +2 -0
  2. package/agent/__tests__/useAgent.test.d.ts.map +1 -0
  3. package/agent/__tests__/useAgent.test.js +86 -0
  4. package/agent/__tests__/useAgent.test.js.map +1 -0
  5. package/agent/__tests__/useDefaultAgent.test.js +106 -223
  6. package/agent/__tests__/useDefaultAgent.test.js.map +1 -1
  7. package/agent/useDefaultAgent.d.ts +9 -0
  8. package/agent/useDefaultAgent.d.ts.map +1 -1
  9. package/agent/useDefaultAgent.js +35 -5
  10. package/agent/useDefaultAgent.js.map +1 -1
  11. package/composer/ComposerToolbar.d.ts +18 -16
  12. package/composer/ComposerToolbar.d.ts.map +1 -1
  13. package/composer/ComposerToolbar.js +10 -11
  14. package/composer/ComposerToolbar.js.map +1 -1
  15. package/composer/ConfigureMenu.d.ts.map +1 -1
  16. package/composer/ConfigureMenu.js +1 -1
  17. package/composer/ConfigureMenu.js.map +1 -1
  18. package/composer/ContextPopover.d.ts +1 -3
  19. package/composer/ContextPopover.d.ts.map +1 -1
  20. package/composer/ContextPopover.js +2 -2
  21. package/composer/ContextPopover.js.map +1 -1
  22. package/composer/SessionComposer.js +5 -5
  23. package/composer/SessionComposer.js.map +1 -1
  24. package/composer/icons.js +3 -3
  25. package/internal/withTimeout.d.ts +8 -0
  26. package/internal/withTimeout.d.ts.map +1 -0
  27. package/internal/withTimeout.js +19 -0
  28. package/internal/withTimeout.js.map +1 -0
  29. package/package.json +4 -4
  30. package/session/__tests__/useCreateSession.test.js +99 -191
  31. package/session/__tests__/useCreateSession.test.js.map +1 -1
  32. package/session/__tests__/useNewSessionFlow.test.js +71 -0
  33. package/session/__tests__/useNewSessionFlow.test.js.map +1 -1
  34. package/session/__tests__/useSession.test.js +71 -108
  35. package/session/__tests__/useSession.test.js.map +1 -1
  36. package/session/__tests__/useSessionList.test.d.ts +2 -0
  37. package/session/__tests__/useSessionList.test.d.ts.map +1 -0
  38. package/session/__tests__/useSessionList.test.js +63 -0
  39. package/session/__tests__/useSessionList.test.js.map +1 -0
  40. package/session/useNewSessionFlow.d.ts.map +1 -1
  41. package/session/useNewSessionFlow.js +13 -7
  42. package/session/useNewSessionFlow.js.map +1 -1
  43. package/src/agent/__tests__/useAgent.test.tsx +116 -0
  44. package/src/agent/__tests__/useDefaultAgent.test.tsx +115 -240
  45. package/src/agent/useDefaultAgent.ts +53 -2
  46. package/src/composer/ComposerToolbar.tsx +76 -96
  47. package/src/composer/ConfigureMenu.tsx +16 -14
  48. package/src/composer/ContextPopover.tsx +11 -11
  49. package/src/composer/SessionComposer.tsx +6 -6
  50. package/src/composer/icons.tsx +6 -6
  51. package/src/internal/withTimeout.ts +25 -0
  52. package/src/session/__tests__/useCreateSession.test.tsx +114 -235
  53. package/src/session/__tests__/useNewSessionFlow.test.tsx +96 -1
  54. package/src/session/__tests__/useSession.test.tsx +82 -141
  55. package/src/session/__tests__/useSessionList.test.tsx +86 -0
  56. package/src/session/useNewSessionFlow.ts +18 -9
  57. package/styles.css +1 -1
@@ -1,296 +1,175 @@
1
- import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
- import { renderHook, act } from "@testing-library/react";
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import { renderHook, waitFor, act } from "@testing-library/react";
3
3
  import type { ReactNode } from "react";
4
- import { Harness } from "@stigmer/protos/ai/stigmer/agentic/session/v1/enum_pb";
5
- import type { Stigmer } from "@stigmer/sdk";
6
4
  import { StigmerContext } from "../../context";
5
+ import { FetchCacheContext } from "../../internal/FetchCacheProvider";
7
6
  import { useCreateSession } from "../useCreateSession";
8
7
 
9
- function buildMockClient(overrides: {
10
- sessionCreate?: ReturnType<typeof vi.fn>;
11
- agentGetByReference?: ReturnType<typeof vi.fn>;
8
+ function createMockStigmer(overrides: {
9
+ getByReference?: (...args: unknown[]) => Promise<unknown>;
10
+ create?: (...args: unknown[]) => Promise<unknown>;
12
11
  } = {}) {
13
12
  return {
14
- session: {
15
- create: overrides.sessionCreate ?? vi.fn(),
16
- },
17
13
  agent: {
18
- getByReference: overrides.agentGetByReference ?? vi.fn(),
14
+ getByReference: overrides.getByReference ?? vi.fn().mockResolvedValue({
15
+ status: { defaultInstanceId: "ain-default" },
16
+ }),
19
17
  },
20
- } as unknown as Stigmer;
21
- }
22
-
23
- function makeWrapper(client: Stigmer) {
24
- return ({ children }: { children: ReactNode }) => (
25
- <StigmerContext.Provider value={client}>{children}</StigmerContext.Provider>
26
- );
18
+ session: {
19
+ create: overrides.create ?? vi.fn().mockResolvedValue({
20
+ metadata: { id: "ses-new-1" },
21
+ }),
22
+ },
23
+ } as never;
27
24
  }
28
25
 
29
- function fakeSessionResponse(sessionId: string) {
30
- return { metadata: { id: sessionId } };
26
+ function wrapper(client: unknown) {
27
+ return function Wrapper({ children }: { children: ReactNode }) {
28
+ return (
29
+ <FetchCacheContext.Provider value={null}>
30
+ <StigmerContext.Provider value={client as never}>
31
+ {children}
32
+ </StigmerContext.Provider>
33
+ </FetchCacheContext.Provider>
34
+ );
35
+ };
31
36
  }
32
37
 
33
38
  describe("useCreateSession", () => {
34
- let sessionCreateMock: ReturnType<typeof vi.fn>;
35
- let agentGetByRefMock: ReturnType<typeof vi.fn>;
36
- let client: Stigmer;
37
-
38
39
  beforeEach(() => {
39
- sessionCreateMock = vi.fn();
40
- agentGetByRefMock = vi.fn();
41
- client = buildMockClient({
42
- sessionCreate: sessionCreateMock,
43
- agentGetByReference: agentGetByRefMock,
44
- });
45
- });
46
-
47
- afterEach(() => {
48
40
  vi.restoreAllMocks();
49
41
  });
50
42
 
51
- it("starts in idle state", () => {
43
+ it("creates a session with agentInstanceId", async () => {
44
+ const create = vi.fn().mockResolvedValue({ metadata: { id: "ses-123" } });
45
+ const client = createMockStigmer({ create });
46
+
52
47
  const { result } = renderHook(() => useCreateSession(), {
53
- wrapper: makeWrapper(client),
48
+ wrapper: wrapper(client),
54
49
  });
55
- expect(result.current.isCreating).toBe(false);
56
- expect(result.current.error).toBeNull();
57
- });
58
-
59
- describe("agentInstanceId path", () => {
60
- it("creates a session with the given instance ID", async () => {
61
- sessionCreateMock.mockResolvedValueOnce(fakeSessionResponse("sess-1"));
62
-
63
- const { result } = renderHook(() => useCreateSession(), {
64
- wrapper: makeWrapper(client),
65
- });
66
50
 
67
- let outcome: { sessionId: string } | undefined;
68
- await act(async () => {
69
- outcome = await result.current.create({
70
- org: "acme",
71
- agentInstanceId: "inst-abc",
72
- });
73
- });
51
+ expect(result.current.isCreating).toBe(false);
74
52
 
75
- expect(outcome!.sessionId).toBe("sess-1");
76
- expect(sessionCreateMock).toHaveBeenCalledOnce();
77
- expect(sessionCreateMock.mock.calls[0][0]).toMatchObject({
53
+ let sessionResult: { sessionId: string } | undefined;
54
+ await act(async () => {
55
+ sessionResult = await result.current.create({
78
56
  org: "acme",
79
- agentInstanceId: "inst-abc",
57
+ agentInstanceId: "ain-123",
80
58
  });
81
59
  });
82
- });
83
60
 
84
- describe("agentRef path", () => {
85
- it("resolves the agent reference to its default instance", async () => {
86
- agentGetByRefMock.mockResolvedValueOnce({
87
- status: { defaultInstanceId: "inst-resolved" },
88
- });
89
- sessionCreateMock.mockResolvedValueOnce(fakeSessionResponse("sess-2"));
61
+ expect(sessionResult!.sessionId).toBe("ses-123");
62
+ expect(result.current.isCreating).toBe(false);
63
+ expect(result.current.error).toBeNull();
64
+ expect(create).toHaveBeenCalledTimes(1);
65
+ expect(create).toHaveBeenCalledWith(
66
+ expect.objectContaining({
67
+ org: "acme",
68
+ agentInstanceId: "ain-123",
69
+ }),
70
+ );
71
+ });
90
72
 
91
- const { result } = renderHook(() => useCreateSession(), {
92
- wrapper: makeWrapper(client),
93
- });
73
+ it("resolves agentRef to default instance before creating", async () => {
74
+ const getByReference = vi.fn().mockResolvedValue({
75
+ status: { defaultInstanceId: "ain-resolved" },
76
+ });
77
+ const create = vi.fn().mockResolvedValue({ metadata: { id: "ses-456" } });
78
+ const client = createMockStigmer({ getByReference, create });
94
79
 
95
- await act(async () => {
96
- await result.current.create({
97
- org: "acme",
98
- agentRef: { org: "acme", slug: "my-agent" },
99
- });
100
- });
80
+ const { result } = renderHook(() => useCreateSession(), {
81
+ wrapper: wrapper(client),
82
+ });
101
83
 
102
- expect(agentGetByRefMock).toHaveBeenCalledWith({
84
+ await act(async () => {
85
+ await result.current.create({
103
86
  org: "acme",
104
- slug: "my-agent",
105
- });
106
- expect(sessionCreateMock.mock.calls[0][0]).toMatchObject({
107
- agentInstanceId: "inst-resolved",
87
+ agentRef: { org: "acme", slug: "my-agent" },
108
88
  });
109
89
  });
110
90
 
111
- it("throws when agent has no default instance", async () => {
112
- agentGetByRefMock.mockResolvedValueOnce({ status: {} });
113
-
114
- const { result } = renderHook(() => useCreateSession(), {
115
- wrapper: makeWrapper(client),
116
- });
117
-
118
- await act(async () => {
119
- await expect(
120
- result.current.create({
121
- org: "acme",
122
- agentRef: { org: "acme", slug: "no-instance" },
123
- }),
124
- ).rejects.toThrow("does not have a default instance");
125
- });
126
-
127
- expect(result.current.error).not.toBeNull();
128
- expect(result.current.error!.message).toContain(
129
- "does not have a default instance",
130
- );
131
- });
91
+ expect(getByReference).toHaveBeenCalledWith({ org: "acme", slug: "my-agent" });
92
+ expect(create).toHaveBeenCalledWith(
93
+ expect.objectContaining({ agentInstanceId: "ain-resolved" }),
94
+ );
132
95
  });
133
96
 
134
- describe("harness proto conversion", () => {
135
- it("passes Harness.CURSOR when harness is cursor", async () => {
136
- sessionCreateMock.mockResolvedValueOnce(fakeSessionResponse("sess-h1"));
97
+ it("errors when agentRef resolves to agent without default instance", async () => {
98
+ const getByReference = vi.fn().mockResolvedValue({
99
+ status: { defaultInstanceId: "" },
100
+ });
101
+ const client = createMockStigmer({ getByReference });
137
102
 
138
- const { result } = renderHook(() => useCreateSession(), {
139
- wrapper: makeWrapper(client),
140
- });
103
+ const { result } = renderHook(() => useCreateSession(), {
104
+ wrapper: wrapper(client),
105
+ });
141
106
 
142
- await act(async () => {
107
+ // The hook catches the error, sets state, and re-throws.
108
+ // We need to catch the rethrow to inspect the error state.
109
+ let caughtError: Error | undefined;
110
+ await act(async () => {
111
+ try {
143
112
  await result.current.create({
144
113
  org: "acme",
145
- agentInstanceId: "inst-1",
146
- harness: "cursor",
114
+ agentRef: { org: "acme", slug: "no-instance-agent" },
147
115
  });
148
- });
149
-
150
- expect(sessionCreateMock.mock.calls[0][0].harness).toBe(Harness.CURSOR);
116
+ } catch (err) {
117
+ caughtError = err as Error;
118
+ }
151
119
  });
152
120
 
153
- it("passes Harness.NATIVE when harness is native", async () => {
154
- sessionCreateMock.mockResolvedValueOnce(fakeSessionResponse("sess-h2"));
155
-
156
- const { result } = renderHook(() => useCreateSession(), {
157
- wrapper: makeWrapper(client),
158
- });
121
+ expect(caughtError).toBeTruthy();
122
+ expect(caughtError!.message).toContain("does not have a default instance");
123
+ expect(result.current.error).toBeTruthy();
124
+ expect(result.current.error!.message).toContain("does not have a default instance");
125
+ });
159
126
 
160
- await act(async () => {
161
- await result.current.create({
162
- org: "acme",
163
- agentInstanceId: "inst-1",
164
- harness: "native",
165
- });
166
- });
127
+ it("sets error state on session.create failure", async () => {
128
+ const create = vi.fn().mockRejectedValue(new Error("Permission denied"));
129
+ const client = createMockStigmer({ create });
167
130
 
168
- expect(sessionCreateMock.mock.calls[0][0].harness).toBe(Harness.NATIVE);
131
+ const { result } = renderHook(() => useCreateSession(), {
132
+ wrapper: wrapper(client),
169
133
  });
170
134
 
171
- it("passes undefined when harness is omitted", async () => {
172
- sessionCreateMock.mockResolvedValueOnce(fakeSessionResponse("sess-h3"));
173
-
174
- const { result } = renderHook(() => useCreateSession(), {
175
- wrapper: makeWrapper(client),
176
- });
177
-
178
- await act(async () => {
135
+ let caughtError: Error | undefined;
136
+ await act(async () => {
137
+ try {
179
138
  await result.current.create({
180
139
  org: "acme",
181
- agentInstanceId: "inst-1",
140
+ agentInstanceId: "ain-123",
182
141
  });
183
- });
184
-
185
- expect(sessionCreateMock.mock.calls[0][0].harness).toBeUndefined();
142
+ } catch (err) {
143
+ caughtError = err as Error;
144
+ }
186
145
  });
187
- });
188
146
 
189
- describe("loading state lifecycle", () => {
190
- it("isCreating is true during the RPC and false after", async () => {
191
- let resolveCreate!: (v: unknown) => void;
192
- sessionCreateMock.mockReturnValueOnce(
193
- new Promise((r) => { resolveCreate = r; }),
194
- );
195
-
196
- const { result } = renderHook(() => useCreateSession(), {
197
- wrapper: makeWrapper(client),
198
- });
199
-
200
- let createPromise: Promise<unknown>;
201
- act(() => {
202
- createPromise = result.current.create({
203
- org: "acme",
204
- agentInstanceId: "inst-1",
205
- });
206
- });
207
-
208
- expect(result.current.isCreating).toBe(true);
209
-
210
- await act(async () => {
211
- resolveCreate(fakeSessionResponse("sess-lc"));
212
- await createPromise;
213
- });
214
-
215
- expect(result.current.isCreating).toBe(false);
216
- });
147
+ expect(caughtError).toBeTruthy();
148
+ expect(caughtError!.message).toBe("Permission denied");
149
+ expect(result.current.error!.message).toBe("Permission denied");
150
+ expect(result.current.isCreating).toBe(false);
217
151
  });
218
152
 
219
- describe("error handling", () => {
220
- it("sets error on RPC failure and resets isCreating", async () => {
221
- sessionCreateMock.mockRejectedValueOnce(new Error("network timeout"));
153
+ it("clearError resets the error state", async () => {
154
+ const create = vi.fn().mockRejectedValue(new Error("fail"));
155
+ const client = createMockStigmer({ create });
222
156
 
223
- const { result } = renderHook(() => useCreateSession(), {
224
- wrapper: makeWrapper(client),
225
- });
226
-
227
- await act(async () => {
228
- await expect(
229
- result.current.create({
230
- org: "acme",
231
- agentInstanceId: "inst-1",
232
- }),
233
- ).rejects.toThrow("network timeout");
234
- });
235
-
236
- expect(result.current.isCreating).toBe(false);
237
- expect(result.current.error).toBeInstanceOf(Error);
238
- expect(result.current.error!.message).toBe("network timeout");
157
+ const { result } = renderHook(() => useCreateSession(), {
158
+ wrapper: wrapper(client),
239
159
  });
240
160
 
241
- it("clearError resets error to null", async () => {
242
- sessionCreateMock.mockRejectedValueOnce(new Error("fail"));
243
-
244
- const { result } = renderHook(() => useCreateSession(), {
245
- wrapper: makeWrapper(client),
246
- });
247
-
248
- await act(async () => {
249
- try {
250
- await result.current.create({
251
- org: "acme",
252
- agentInstanceId: "inst-1",
253
- });
254
- } catch { /* expected */ }
255
- });
256
-
257
- expect(result.current.error).not.toBeNull();
258
-
259
- act(() => {
260
- result.current.clearError();
261
- });
262
-
263
- expect(result.current.error).toBeNull();
161
+ await act(async () => {
162
+ try {
163
+ await result.current.create({ org: "acme", agentInstanceId: "ain-1" });
164
+ } catch {
165
+ // expected
166
+ }
264
167
  });
265
168
 
266
- it("clears previous error on new create attempt", async () => {
267
- sessionCreateMock
268
- .mockRejectedValueOnce(new Error("first fail"))
269
- .mockResolvedValueOnce(fakeSessionResponse("sess-retry"));
270
-
271
- const { result } = renderHook(() => useCreateSession(), {
272
- wrapper: makeWrapper(client),
273
- });
274
-
275
- await act(async () => {
276
- try {
277
- await result.current.create({
278
- org: "acme",
279
- agentInstanceId: "inst-1",
280
- });
281
- } catch { /* expected */ }
282
- });
283
-
284
- expect(result.current.error).not.toBeNull();
169
+ expect(result.current.error).toBeTruthy();
285
170
 
286
- await act(async () => {
287
- await result.current.create({
288
- org: "acme",
289
- agentInstanceId: "inst-1",
290
- });
291
- });
171
+ act(() => result.current.clearError());
292
172
 
293
- expect(result.current.error).toBeNull();
294
- });
173
+ expect(result.current.error).toBeNull();
295
174
  });
296
175
  });
@@ -29,8 +29,9 @@ vi.mock("../../execution/useCreateAgentExecution", () => ({
29
29
  const mockDefaultAgent = {
30
30
  agent: null as { status?: { defaultInstanceId?: string } } | null,
31
31
  isLoading: false,
32
- error: null,
32
+ error: null as Error | null,
33
33
  refetch: vi.fn(),
34
+ waitForResolution: vi.fn<() => Promise<unknown>>(),
34
35
  };
35
36
  vi.mock("../../agent", () => ({
36
37
  useDefaultAgent: () => mockDefaultAgent,
@@ -379,4 +380,98 @@ describe("useNewSessionFlow", () => {
379
380
  expect(result.current.isSubmitting).toBe(false);
380
381
  });
381
382
  });
383
+
384
+ describe("submit while default agent is loading", () => {
385
+ it("awaits default agent and creates session when fetch resolves", async () => {
386
+ const resolvedAgent = { status: { defaultInstanceId: "awaited-inst" } };
387
+ mockDefaultAgent.agent = null;
388
+ mockDefaultAgent.isLoading = true;
389
+ mockDefaultAgent.error = null;
390
+ mockDefaultAgent.waitForResolution.mockResolvedValue(resolvedAgent);
391
+
392
+ const opts = defaultOptions();
393
+ const { result } = renderHook(() => useNewSessionFlow(opts), { wrapper: createWrapper() });
394
+
395
+ await act(async () => {
396
+ await result.current.submit("Hello");
397
+ });
398
+
399
+ expect(mockDefaultAgent.waitForResolution).toHaveBeenCalledOnce();
400
+ expect(mockCreateSession).toHaveBeenCalledOnce();
401
+ expect(mockCreateSession.mock.calls[0][0].agentInstanceId).toBe("awaited-inst");
402
+ expect(opts.onSessionCreated).toHaveBeenCalledWith("sess-new");
403
+ expect(result.current.submitError).toBeNull();
404
+ });
405
+
406
+ it("surfaces error when fetch fails during await", async () => {
407
+ mockDefaultAgent.agent = null;
408
+ mockDefaultAgent.isLoading = true;
409
+ mockDefaultAgent.error = null;
410
+ mockDefaultAgent.waitForResolution.mockRejectedValue(
411
+ new Error("Network failure"),
412
+ );
413
+
414
+ const opts = defaultOptions();
415
+ const { result } = renderHook(() => useNewSessionFlow(opts), { wrapper: createWrapper() });
416
+
417
+ await act(async () => {
418
+ await result.current.submit("Hello");
419
+ });
420
+
421
+ expect(result.current.submitError).toBeTruthy();
422
+ expect(opts.onError).toHaveBeenCalled();
423
+ expect(mockCreateSession).not.toHaveBeenCalled();
424
+ });
425
+
426
+ it("surfaces timeout error when fetch never resolves", async () => {
427
+ vi.useFakeTimers();
428
+
429
+ mockDefaultAgent.agent = null;
430
+ mockDefaultAgent.isLoading = true;
431
+ mockDefaultAgent.error = null;
432
+ mockDefaultAgent.waitForResolution.mockReturnValue(
433
+ new Promise(() => {}),
434
+ );
435
+
436
+ const opts = defaultOptions();
437
+ const { result } = renderHook(() => useNewSessionFlow(opts), { wrapper: createWrapper() });
438
+
439
+ let submitPromise: Promise<void>;
440
+ act(() => {
441
+ submitPromise = result.current.submit("Hello") as unknown as Promise<void>;
442
+ });
443
+
444
+ await act(async () => {
445
+ vi.advanceTimersByTime(10_000);
446
+ });
447
+
448
+ await act(async () => {
449
+ await submitPromise;
450
+ });
451
+
452
+ expect(result.current.submitError).toContain("did not load in time");
453
+ expect(opts.onError).toHaveBeenCalled();
454
+ expect(mockCreateSession).not.toHaveBeenCalled();
455
+
456
+ vi.useRealTimers();
457
+ });
458
+
459
+ it("errors immediately when default agent has already failed", async () => {
460
+ mockDefaultAgent.agent = null;
461
+ mockDefaultAgent.isLoading = false;
462
+ mockDefaultAgent.error = new Error("Already failed");
463
+
464
+ const opts = defaultOptions();
465
+ const { result } = renderHook(() => useNewSessionFlow(opts), { wrapper: createWrapper() });
466
+
467
+ await act(async () => {
468
+ await result.current.submit("Hello");
469
+ });
470
+
471
+ expect(result.current.submitError).toContain("Failed to load default agent");
472
+ expect(opts.onError).toHaveBeenCalled();
473
+ expect(mockCreateSession).not.toHaveBeenCalled();
474
+ expect(mockDefaultAgent.waitForResolution).not.toHaveBeenCalled();
475
+ });
476
+ });
382
477
  });