@stigmer/react 1.0.2 → 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.
- package/agent/__tests__/useAgent.test.d.ts +2 -0
- package/agent/__tests__/useAgent.test.d.ts.map +1 -0
- package/agent/__tests__/useAgent.test.js +86 -0
- package/agent/__tests__/useAgent.test.js.map +1 -0
- package/agent/__tests__/useDefaultAgent.test.js +106 -223
- package/agent/__tests__/useDefaultAgent.test.js.map +1 -1
- package/agent/useDefaultAgent.d.ts +9 -0
- package/agent/useDefaultAgent.d.ts.map +1 -1
- package/agent/useDefaultAgent.js +35 -5
- package/agent/useDefaultAgent.js.map +1 -1
- package/composer/ComposerToolbar.d.ts +18 -16
- package/composer/ComposerToolbar.d.ts.map +1 -1
- package/composer/ComposerToolbar.js +10 -11
- package/composer/ComposerToolbar.js.map +1 -1
- package/composer/ConfigureMenu.d.ts.map +1 -1
- package/composer/ConfigureMenu.js +1 -1
- package/composer/ConfigureMenu.js.map +1 -1
- package/composer/ContextPopover.d.ts +1 -3
- package/composer/ContextPopover.d.ts.map +1 -1
- package/composer/ContextPopover.js +2 -2
- package/composer/ContextPopover.js.map +1 -1
- package/composer/SessionComposer.js +5 -5
- package/composer/SessionComposer.js.map +1 -1
- package/composer/icons.js +3 -3
- package/internal/withTimeout.d.ts +8 -0
- package/internal/withTimeout.d.ts.map +1 -0
- package/internal/withTimeout.js +19 -0
- package/internal/withTimeout.js.map +1 -0
- package/package.json +4 -4
- package/session/__tests__/useCreateSession.test.js +99 -191
- package/session/__tests__/useCreateSession.test.js.map +1 -1
- package/session/__tests__/useNewSessionFlow.test.js +71 -0
- package/session/__tests__/useNewSessionFlow.test.js.map +1 -1
- package/session/__tests__/useSession.test.js +71 -108
- package/session/__tests__/useSession.test.js.map +1 -1
- package/session/__tests__/useSessionList.test.d.ts +2 -0
- package/session/__tests__/useSessionList.test.d.ts.map +1 -0
- package/session/__tests__/useSessionList.test.js +63 -0
- package/session/__tests__/useSessionList.test.js.map +1 -0
- package/session/useNewSessionFlow.d.ts.map +1 -1
- package/session/useNewSessionFlow.js +13 -7
- package/session/useNewSessionFlow.js.map +1 -1
- package/src/agent/__tests__/useAgent.test.tsx +116 -0
- package/src/agent/__tests__/useDefaultAgent.test.tsx +115 -240
- package/src/agent/useDefaultAgent.ts +53 -2
- package/src/composer/ComposerToolbar.tsx +76 -96
- package/src/composer/ConfigureMenu.tsx +16 -14
- package/src/composer/ContextPopover.tsx +11 -11
- package/src/composer/SessionComposer.tsx +6 -6
- package/src/composer/icons.tsx +6 -6
- package/src/internal/withTimeout.ts +25 -0
- package/src/session/__tests__/useCreateSession.test.tsx +114 -235
- package/src/session/__tests__/useNewSessionFlow.test.tsx +96 -1
- package/src/session/__tests__/useSession.test.tsx +82 -141
- package/src/session/__tests__/useSessionList.test.tsx +86 -0
- package/src/session/useNewSessionFlow.ts +18 -9
- package/styles.css +1 -1
|
@@ -1,308 +1,183 @@
|
|
|
1
|
-
import { describe, it, expect, vi, beforeEach
|
|
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 type { Agent } from "@stigmer/protos/ai/stigmer/agentic/agent/v1/api_pb";
|
|
5
|
-
import type { Stigmer } from "@stigmer/sdk";
|
|
6
4
|
import { StigmerContext } from "../../context";
|
|
5
|
+
import { FetchCacheContext } from "../../internal/FetchCacheProvider";
|
|
7
6
|
import { useDefaultAgent } from "../useDefaultAgent";
|
|
8
7
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
function fakeAgent(instanceId: string): Agent {
|
|
12
|
-
return {
|
|
13
|
-
status: { defaultInstanceId: instanceId },
|
|
14
|
-
} as unknown as Agent;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
function buildMockClient(overrides: {
|
|
18
|
-
getDefault?: ReturnType<typeof vi.fn>;
|
|
8
|
+
function createMockStigmer(overrides: {
|
|
9
|
+
getDefault?: () => Promise<unknown>;
|
|
19
10
|
} = {}) {
|
|
20
11
|
return {
|
|
21
12
|
agent: {
|
|
22
|
-
getDefault: overrides.getDefault ?? vi.fn(),
|
|
13
|
+
getDefault: overrides.getDefault ?? vi.fn().mockResolvedValue(null),
|
|
23
14
|
},
|
|
24
|
-
} as
|
|
15
|
+
} as never;
|
|
25
16
|
}
|
|
26
17
|
|
|
27
|
-
function
|
|
28
|
-
return ({ children }: { children: ReactNode })
|
|
29
|
-
|
|
30
|
-
{
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
value: state,
|
|
38
|
-
writable: true,
|
|
39
|
-
configurable: true,
|
|
40
|
-
});
|
|
41
|
-
document.dispatchEvent(new Event("visibilitychange"));
|
|
18
|
+
function wrapper(client: unknown) {
|
|
19
|
+
return function Wrapper({ children }: { children: ReactNode }) {
|
|
20
|
+
return (
|
|
21
|
+
<FetchCacheContext.Provider value={null}>
|
|
22
|
+
<StigmerContext.Provider value={client as never}>
|
|
23
|
+
{children}
|
|
24
|
+
</StigmerContext.Provider>
|
|
25
|
+
</FetchCacheContext.Provider>
|
|
26
|
+
);
|
|
27
|
+
};
|
|
42
28
|
}
|
|
43
29
|
|
|
44
30
|
describe("useDefaultAgent", () => {
|
|
45
|
-
let getDefaultMock: ReturnType<typeof vi.fn>;
|
|
46
|
-
let client: Stigmer;
|
|
47
|
-
|
|
48
31
|
beforeEach(() => {
|
|
49
|
-
getDefaultMock = vi.fn();
|
|
50
|
-
client = buildMockClient({ getDefault: getDefaultMock });
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
afterEach(() => {
|
|
54
32
|
vi.restoreAllMocks();
|
|
55
33
|
});
|
|
56
34
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
35
|
+
it("returns loading state initially and resolves with agent data", async () => {
|
|
36
|
+
const agent = { metadata: { id: "agt-1", name: "Default Agent" }, status: { defaultInstanceId: "ain-1" } };
|
|
37
|
+
const getDefault = vi.fn().mockResolvedValue(agent);
|
|
38
|
+
const client = createMockStigmer({ getDefault });
|
|
60
39
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
getDefaultMock.mockResolvedValueOnce(agent);
|
|
64
|
-
|
|
65
|
-
const { result } = renderHook(() => useDefaultAgent("acme"), {
|
|
66
|
-
wrapper: makeWrapper(client),
|
|
40
|
+
const { result } = renderHook(() => useDefaultAgent("test-org"), {
|
|
41
|
+
wrapper: wrapper(client),
|
|
67
42
|
});
|
|
68
43
|
|
|
69
44
|
expect(result.current.isLoading).toBe(true);
|
|
45
|
+
expect(result.current.agent).toBeNull();
|
|
70
46
|
|
|
71
|
-
await
|
|
47
|
+
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
|
72
48
|
|
|
73
|
-
expect(result.current.isLoading).toBe(false);
|
|
74
49
|
expect(result.current.agent).toBe(agent);
|
|
75
50
|
expect(result.current.error).toBeNull();
|
|
76
|
-
expect(
|
|
51
|
+
expect(getDefault).toHaveBeenCalledTimes(1);
|
|
77
52
|
});
|
|
78
53
|
|
|
79
54
|
it("skips fetching when org is null", () => {
|
|
55
|
+
const getDefault = vi.fn();
|
|
56
|
+
const client = createMockStigmer({ getDefault });
|
|
57
|
+
|
|
80
58
|
const { result } = renderHook(() => useDefaultAgent(null), {
|
|
81
|
-
wrapper:
|
|
59
|
+
wrapper: wrapper(client),
|
|
82
60
|
});
|
|
83
61
|
|
|
84
|
-
expect(result.current.agent).toBeNull();
|
|
85
62
|
expect(result.current.isLoading).toBe(false);
|
|
86
|
-
expect(
|
|
63
|
+
expect(result.current.agent).toBeNull();
|
|
64
|
+
expect(result.current.error).toBeNull();
|
|
65
|
+
expect(getDefault).not.toHaveBeenCalled();
|
|
87
66
|
});
|
|
88
67
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
it("retries once on transient failure then succeeds", async () => {
|
|
94
|
-
vi.useFakeTimers();
|
|
95
|
-
try {
|
|
96
|
-
const agent = fakeAgent("inst_retry");
|
|
97
|
-
getDefaultMock
|
|
98
|
-
.mockRejectedValueOnce(new Error("network timeout"))
|
|
99
|
-
.mockResolvedValueOnce(agent);
|
|
100
|
-
|
|
101
|
-
const { result } = renderHook(() => useDefaultAgent("acme"), {
|
|
102
|
-
wrapper: makeWrapper(client),
|
|
103
|
-
});
|
|
104
|
-
|
|
105
|
-
expect(result.current.isLoading).toBe(true);
|
|
106
|
-
|
|
107
|
-
// Flush the rejected first attempt.
|
|
108
|
-
await act(async () => {
|
|
109
|
-
await vi.advanceTimersByTimeAsync(0);
|
|
110
|
-
});
|
|
111
|
-
|
|
112
|
-
// Advance past the 1s retry delay.
|
|
113
|
-
await act(async () => {
|
|
114
|
-
await vi.advanceTimersByTimeAsync(1_100);
|
|
115
|
-
});
|
|
68
|
+
it("exposes error when fetch fails after retry", async () => {
|
|
69
|
+
const apiError = new Error("Service unavailable");
|
|
70
|
+
const getDefault = vi.fn().mockRejectedValue(apiError);
|
|
71
|
+
const client = createMockStigmer({ getDefault });
|
|
116
72
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
expect(getDefaultMock).toHaveBeenCalledTimes(2);
|
|
121
|
-
} finally {
|
|
122
|
-
vi.useRealTimers();
|
|
123
|
-
}
|
|
124
|
-
});
|
|
125
|
-
|
|
126
|
-
it("surfaces the error after exhausting retries", async () => {
|
|
127
|
-
vi.useFakeTimers();
|
|
128
|
-
try {
|
|
129
|
-
getDefaultMock
|
|
130
|
-
.mockRejectedValueOnce(new Error("fail 1"))
|
|
131
|
-
.mockRejectedValueOnce(new Error("fail 2"));
|
|
73
|
+
const { result } = renderHook(() => useDefaultAgent("test-org"), {
|
|
74
|
+
wrapper: wrapper(client),
|
|
75
|
+
});
|
|
132
76
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
77
|
+
// useDefaultAgent retries once (MAX_RETRIES=1) with a 1s delay.
|
|
78
|
+
// useFetch wraps the retry fn, so the final error surfaces after
|
|
79
|
+
// both attempts fail. Wait for the error to propagate.
|
|
80
|
+
await waitFor(() => expect(result.current.error).toBeTruthy(), { timeout: 10_000 });
|
|
136
81
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
82
|
+
expect(result.current.error!.message).toBe("Service unavailable");
|
|
83
|
+
expect(result.current.agent).toBeNull();
|
|
84
|
+
expect(result.current.isLoading).toBe(false);
|
|
85
|
+
// 1 initial + 1 retry = 2 calls
|
|
86
|
+
expect(getDefault).toHaveBeenCalledTimes(2);
|
|
87
|
+
}, 15_000);
|
|
88
|
+
|
|
89
|
+
it("refetch triggers a new fetch", async () => {
|
|
90
|
+
let callCount = 0;
|
|
91
|
+
const agent = { metadata: { id: "agt-1" }, status: { defaultInstanceId: "ain-1" } };
|
|
92
|
+
const getDefault = vi.fn().mockImplementation(async () => {
|
|
93
|
+
callCount++;
|
|
94
|
+
return agent;
|
|
95
|
+
});
|
|
96
|
+
const client = createMockStigmer({ getDefault });
|
|
144
97
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
expect(result.current.error!.message).toBe("fail 2");
|
|
149
|
-
expect(getDefaultMock).toHaveBeenCalledTimes(2);
|
|
150
|
-
} finally {
|
|
151
|
-
vi.useRealTimers();
|
|
152
|
-
}
|
|
153
|
-
});
|
|
98
|
+
const { result } = renderHook(() => useDefaultAgent("test-org"), {
|
|
99
|
+
wrapper: wrapper(client),
|
|
100
|
+
});
|
|
154
101
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
// -----------------------------------------------------------------------
|
|
158
|
-
|
|
159
|
-
it("refetches when document becomes visible after stale threshold", async () => {
|
|
160
|
-
vi.useFakeTimers();
|
|
161
|
-
try {
|
|
162
|
-
const agent1 = fakeAgent("inst_old");
|
|
163
|
-
const agent2 = fakeAgent("inst_new");
|
|
164
|
-
getDefaultMock
|
|
165
|
-
.mockResolvedValueOnce(agent1)
|
|
166
|
-
.mockResolvedValueOnce(agent2);
|
|
167
|
-
|
|
168
|
-
const { result } = renderHook(() => useDefaultAgent("acme"), {
|
|
169
|
-
wrapper: makeWrapper(client),
|
|
170
|
-
});
|
|
102
|
+
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
|
103
|
+
expect(callCount).toBe(1);
|
|
171
104
|
|
|
172
|
-
|
|
173
|
-
await act(async () => {
|
|
174
|
-
await vi.advanceTimersByTimeAsync(0);
|
|
175
|
-
});
|
|
176
|
-
expect(result.current.agent).toBe(agent1);
|
|
177
|
-
expect(getDefaultMock).toHaveBeenCalledTimes(1);
|
|
105
|
+
act(() => result.current.refetch());
|
|
178
106
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
await vi.advanceTimersByTimeAsync(STALE_THRESHOLD_MS + 1_000);
|
|
182
|
-
});
|
|
107
|
+
await waitFor(() => expect(callCount).toBe(2), { timeout: 10_000 });
|
|
108
|
+
}, 15_000);
|
|
183
109
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
110
|
+
describe("waitForResolution", () => {
|
|
111
|
+
it("resolves immediately when agent is already loaded", async () => {
|
|
112
|
+
const agent = { metadata: { id: "agt-1" }, status: { defaultInstanceId: "ain-1" } };
|
|
113
|
+
const getDefault = vi.fn().mockResolvedValue(agent);
|
|
114
|
+
const client = createMockStigmer({ getDefault });
|
|
188
115
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
await vi.advanceTimersByTimeAsync(0);
|
|
116
|
+
const { result } = renderHook(() => useDefaultAgent("test-org"), {
|
|
117
|
+
wrapper: wrapper(client),
|
|
192
118
|
});
|
|
193
119
|
|
|
194
|
-
expect(result.current.
|
|
195
|
-
expect(getDefaultMock).toHaveBeenCalledTimes(2);
|
|
196
|
-
} finally {
|
|
197
|
-
vi.useRealTimers();
|
|
198
|
-
}
|
|
199
|
-
});
|
|
120
|
+
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
|
200
121
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
const agent = fakeAgent("inst_fresh");
|
|
205
|
-
getDefaultMock.mockResolvedValueOnce(agent);
|
|
122
|
+
const resolved = await result.current.waitForResolution();
|
|
123
|
+
expect(resolved).toBe(agent);
|
|
124
|
+
});
|
|
206
125
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
});
|
|
126
|
+
it("awaits and resolves when fetch completes", async () => {
|
|
127
|
+
let resolveExternal!: (value: unknown) => void;
|
|
128
|
+
const fetchPromise = new Promise((res) => { resolveExternal = res; });
|
|
129
|
+
const getDefault = vi.fn().mockReturnValue(fetchPromise);
|
|
130
|
+
const client = createMockStigmer({ getDefault });
|
|
210
131
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
await vi.advanceTimersByTimeAsync(0);
|
|
132
|
+
const { result } = renderHook(() => useDefaultAgent("test-org"), {
|
|
133
|
+
wrapper: wrapper(client),
|
|
214
134
|
});
|
|
215
|
-
expect(result.current.agent).toBe(agent);
|
|
216
|
-
expect(getDefaultMock).toHaveBeenCalledTimes(1);
|
|
217
135
|
|
|
218
|
-
|
|
219
|
-
await act(async () => {
|
|
220
|
-
await vi.advanceTimersByTimeAsync(5_000);
|
|
221
|
-
});
|
|
136
|
+
expect(result.current.isLoading).toBe(true);
|
|
222
137
|
|
|
223
|
-
|
|
224
|
-
fireVisibilityChange("visible");
|
|
225
|
-
});
|
|
138
|
+
const waitPromise = result.current.waitForResolution();
|
|
226
139
|
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
vi.useRealTimers();
|
|
230
|
-
}
|
|
231
|
-
});
|
|
140
|
+
const agent = { metadata: { id: "agt-1" }, status: { defaultInstanceId: "ain-1" } };
|
|
141
|
+
resolveExternal(agent);
|
|
232
142
|
|
|
233
|
-
|
|
234
|
-
vi.useFakeTimers();
|
|
235
|
-
try {
|
|
236
|
-
const agent = fakeAgent("inst_1");
|
|
237
|
-
getDefaultMock.mockResolvedValueOnce(agent);
|
|
143
|
+
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
|
238
144
|
|
|
239
|
-
const
|
|
240
|
-
|
|
241
|
-
|
|
145
|
+
const resolved = await waitPromise;
|
|
146
|
+
expect(resolved).toBe(agent);
|
|
147
|
+
});
|
|
242
148
|
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
});
|
|
247
|
-
expect(result.current.agent).toBe(agent);
|
|
149
|
+
it("rejects when fetch fails", async () => {
|
|
150
|
+
const apiError = new Error("Service unavailable");
|
|
151
|
+
const getDefault = vi.fn().mockRejectedValue(apiError);
|
|
152
|
+
const client = createMockStigmer({ getDefault });
|
|
248
153
|
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
await vi.advanceTimersByTimeAsync(STALE_THRESHOLD_MS + 1_000);
|
|
154
|
+
const { result } = renderHook(() => useDefaultAgent("test-org"), {
|
|
155
|
+
wrapper: wrapper(client),
|
|
252
156
|
});
|
|
253
157
|
|
|
254
|
-
|
|
255
|
-
act(() => {
|
|
256
|
-
fireVisibilityChange("hidden");
|
|
257
|
-
});
|
|
158
|
+
expect(result.current.isLoading).toBe(true);
|
|
258
159
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
}
|
|
263
|
-
});
|
|
160
|
+
const waitPromise = result.current.waitForResolution();
|
|
161
|
+
// Prevent unhandled rejection warning — assertion below verifies the value
|
|
162
|
+
waitPromise.catch(() => {});
|
|
264
163
|
|
|
265
|
-
|
|
266
|
-
// Manual refetch
|
|
267
|
-
// -----------------------------------------------------------------------
|
|
268
|
-
|
|
269
|
-
it("recovers via manual refetch after initial failure", async () => {
|
|
270
|
-
vi.useFakeTimers();
|
|
271
|
-
try {
|
|
272
|
-
const agent = fakeAgent("inst_recovered");
|
|
273
|
-
getDefaultMock
|
|
274
|
-
.mockRejectedValueOnce(new Error("initial fail"))
|
|
275
|
-
.mockRejectedValueOnce(new Error("retry fail"))
|
|
276
|
-
.mockResolvedValueOnce(agent);
|
|
277
|
-
|
|
278
|
-
const { result } = renderHook(() => useDefaultAgent("acme"), {
|
|
279
|
-
wrapper: makeWrapper(client),
|
|
280
|
-
});
|
|
164
|
+
await waitFor(() => expect(result.current.error).toBeTruthy(), { timeout: 10_000 });
|
|
281
165
|
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
await vi.advanceTimersByTimeAsync(0);
|
|
285
|
-
});
|
|
286
|
-
await act(async () => {
|
|
287
|
-
await vi.advanceTimersByTimeAsync(1_100);
|
|
288
|
-
});
|
|
166
|
+
await expect(waitPromise).rejects.toThrow("Service unavailable");
|
|
167
|
+
}, 15_000);
|
|
289
168
|
|
|
290
|
-
|
|
169
|
+
it("rejects immediately when error is already set", async () => {
|
|
170
|
+
const apiError = new Error("Already failed");
|
|
171
|
+
const getDefault = vi.fn().mockRejectedValue(apiError);
|
|
172
|
+
const client = createMockStigmer({ getDefault });
|
|
291
173
|
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
result.current.refetch();
|
|
174
|
+
const { result } = renderHook(() => useDefaultAgent("test-org"), {
|
|
175
|
+
wrapper: wrapper(client),
|
|
295
176
|
});
|
|
296
177
|
|
|
297
|
-
|
|
298
|
-
await act(async () => {
|
|
299
|
-
await vi.advanceTimersByTimeAsync(0);
|
|
300
|
-
});
|
|
178
|
+
await waitFor(() => expect(result.current.error).toBeTruthy(), { timeout: 10_000 });
|
|
301
179
|
|
|
302
|
-
expect(result.current.
|
|
303
|
-
|
|
304
|
-
} finally {
|
|
305
|
-
vi.useRealTimers();
|
|
306
|
-
}
|
|
180
|
+
await expect(result.current.waitForResolution()).rejects.toThrow("Already failed");
|
|
181
|
+
}, 15_000);
|
|
307
182
|
});
|
|
308
183
|
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { useEffect, useRef } from "react";
|
|
3
|
+
import { useCallback, useEffect, useRef } from "react";
|
|
4
4
|
import { create } from "@bufbuild/protobuf";
|
|
5
5
|
import type { Agent } from "@stigmer/protos/ai/stigmer/agentic/agent/v1/api_pb";
|
|
6
6
|
import { GetDefaultAgentRequestSchema } from "@stigmer/protos/ai/stigmer/agentic/agent/v1/io_pb";
|
|
@@ -29,6 +29,15 @@ export interface UseDefaultAgentReturn {
|
|
|
29
29
|
readonly error: Error | null;
|
|
30
30
|
/** Discard cached data and re-fetch the default agent from the server. */
|
|
31
31
|
readonly refetch: () => void;
|
|
32
|
+
/**
|
|
33
|
+
* Returns a promise that resolves with the Agent once the in-flight
|
|
34
|
+
* fetch settles. If the agent is already loaded, resolves immediately.
|
|
35
|
+
* If the fetch has already failed, rejects immediately.
|
|
36
|
+
*
|
|
37
|
+
* Use this to await the default agent in async flows (e.g. submit)
|
|
38
|
+
* instead of failing when `isLoading` is still true.
|
|
39
|
+
*/
|
|
40
|
+
readonly waitForResolution: () => Promise<Agent>;
|
|
32
41
|
}
|
|
33
42
|
|
|
34
43
|
/**
|
|
@@ -74,6 +83,32 @@ export function useDefaultAgent(org: string | null): UseDefaultAgentReturn {
|
|
|
74
83
|
null,
|
|
75
84
|
);
|
|
76
85
|
|
|
86
|
+
// Deferred pattern: allows callers to await the in-flight fetch.
|
|
87
|
+
const deferredRef = useRef<Deferred<Agent> | null>(null);
|
|
88
|
+
|
|
89
|
+
useEffect(() => {
|
|
90
|
+
if (agent && deferredRef.current) {
|
|
91
|
+
deferredRef.current.resolve(agent);
|
|
92
|
+
deferredRef.current = null;
|
|
93
|
+
}
|
|
94
|
+
}, [agent]);
|
|
95
|
+
|
|
96
|
+
useEffect(() => {
|
|
97
|
+
if (error && deferredRef.current) {
|
|
98
|
+
deferredRef.current.reject(error);
|
|
99
|
+
deferredRef.current = null;
|
|
100
|
+
}
|
|
101
|
+
}, [error]);
|
|
102
|
+
|
|
103
|
+
const waitForResolution = useCallback((): Promise<Agent> => {
|
|
104
|
+
if (agent) return Promise.resolve(agent);
|
|
105
|
+
if (error) return Promise.reject(error);
|
|
106
|
+
if (!deferredRef.current) {
|
|
107
|
+
deferredRef.current = createDeferred<Agent>();
|
|
108
|
+
}
|
|
109
|
+
return deferredRef.current.promise;
|
|
110
|
+
}, [agent, error]);
|
|
111
|
+
|
|
77
112
|
useEffect(() => {
|
|
78
113
|
if (typeof document === "undefined") return;
|
|
79
114
|
|
|
@@ -88,13 +123,29 @@ export function useDefaultAgent(org: string | null): UseDefaultAgentReturn {
|
|
|
88
123
|
return () => document.removeEventListener("visibilitychange", onVisible);
|
|
89
124
|
}, [refetch]);
|
|
90
125
|
|
|
91
|
-
return { agent, isLoading, isRefetching, error, refetch };
|
|
126
|
+
return { agent, isLoading, isRefetching, error, refetch, waitForResolution };
|
|
92
127
|
}
|
|
93
128
|
|
|
94
129
|
// ---------------------------------------------------------------------------
|
|
95
130
|
// Internal helpers
|
|
96
131
|
// ---------------------------------------------------------------------------
|
|
97
132
|
|
|
133
|
+
interface Deferred<T> {
|
|
134
|
+
promise: Promise<T>;
|
|
135
|
+
resolve: (value: T) => void;
|
|
136
|
+
reject: (reason: unknown) => void;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function createDeferred<T>(): Deferred<T> {
|
|
140
|
+
let resolve!: (value: T) => void;
|
|
141
|
+
let reject!: (reason: unknown) => void;
|
|
142
|
+
const promise = new Promise<T>((res, rej) => {
|
|
143
|
+
resolve = res;
|
|
144
|
+
reject = rej;
|
|
145
|
+
});
|
|
146
|
+
return { promise, resolve, reject };
|
|
147
|
+
}
|
|
148
|
+
|
|
98
149
|
/**
|
|
99
150
|
* Retries `fn` up to `retries` times with a fixed delay between attempts.
|
|
100
151
|
*/
|