@antelopejs/dms-ai 0.2.1 → 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.
- package/README.md +1 -1
- package/dist/constants/frontend-module.d.ts +2 -0
- package/dist/constants/frontend-module.js +6 -1
- package/dist/constants/frontend-module.js.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/frontend-vue/app/components/ActivityDetail.vue +11 -13
- package/frontend-vue/app/components/ChangesView.vue +56 -49
- package/frontend-vue/app/plugins/ai.client.ts +16 -6
- package/frontend-vue/app/runtime/assistant-activity.ts +38 -21
- package/frontend-vue/app/runtime/sidecar-status.ts +78 -56
- package/frontend-vue/app/views/ChangeSetPanel.vue +13 -18
- package/frontend-vue/app/views/UnavailableState.vue +1 -1
- package/frontend-vue/app/views/composables/useAiData.ts +116 -0
- package/frontend-vue/app/views/composables/useAssistant.ts +8 -2
- package/frontend-vue/app/views/composables/useChangeSetActions.ts +7 -6
- package/frontend-vue/app/views/utils/change-sets.ts +0 -7
- package/frontend-vue/package.json +1 -1
- package/frontend-vue/tests/assistant-activity.test.ts +69 -0
- package/frontend-vue/tests/sidecar-guest.test.ts +1 -1
- package/frontend-vue/tests/sidecar-status.test.ts +93 -15
- package/frontend-vue/tests/support/dms-frontend-module.ts +9 -0
- package/frontend-vue/tests/views/api-data-reads.test.ts +445 -0
- package/frontend-vue/tests/views/use-assistant.test.ts +53 -0
- package/frontend-vue/vitest.config.ts +10 -0
- package/package.json +2 -2
- package/frontend-vue/app/views/composables/useAiRequest.ts +0 -89
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { flushPromises, shallowMount } from "@vue/test-utils";
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
import {
|
|
5
|
+
computed,
|
|
6
|
+
defineComponent,
|
|
7
|
+
h,
|
|
8
|
+
reactive,
|
|
9
|
+
ref,
|
|
10
|
+
toValue,
|
|
11
|
+
watch,
|
|
12
|
+
type MaybeRefOrGetter,
|
|
13
|
+
} from "vue";
|
|
14
|
+
import ActivityDetail from "../../app/components/ActivityDetail.vue";
|
|
15
|
+
import ChangesView from "../../app/components/ChangesView.vue";
|
|
16
|
+
import ChangeSetPanel from "../../app/views/ChangeSetPanel.vue";
|
|
17
|
+
import UnavailableState from "../../app/views/UnavailableState.vue";
|
|
18
|
+
|
|
19
|
+
// The views read what they render with the DMS's `useApiData`, which a server
|
|
20
|
+
// render hands to the browser (this repository has no renderer to run it:
|
|
21
|
+
// the playground proves that part), and the Changes list page by page with
|
|
22
|
+
// `useApiPagedData`; `$authFetch` is left to the writes. These fakes read as
|
|
23
|
+
// the browser does.
|
|
24
|
+
|
|
25
|
+
const route = reactive({ query: {} as Record<string, string> });
|
|
26
|
+
|
|
27
|
+
vi.mock("#dms/frontend-module", () => ({
|
|
28
|
+
useDmsRoute: () => route,
|
|
29
|
+
useDmsRouter: () => ({ push: vi.fn() }),
|
|
30
|
+
}));
|
|
31
|
+
|
|
32
|
+
interface Read {
|
|
33
|
+
url: string;
|
|
34
|
+
query?: Record<string, unknown>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
type Backend = (read: Read) => unknown;
|
|
38
|
+
|
|
39
|
+
let backend: Backend;
|
|
40
|
+
const reads: Read[] = [];
|
|
41
|
+
const authFetch = vi.fn();
|
|
42
|
+
/** The reads the page's refreshes read again, as the DMS's `refreshPageBlocks()` does. */
|
|
43
|
+
const pageReads = new Set<() => Promise<void>>();
|
|
44
|
+
const refreshPageBlocks = vi.fn(async () => {
|
|
45
|
+
await Promise.all([...pageReads].map((refresh) => refresh()));
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
interface FakeApiDataOptions {
|
|
49
|
+
query?: MaybeRefOrGetter<Record<string, unknown>>;
|
|
50
|
+
refreshOnPageRefresh?: boolean;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function useApiData<T>(
|
|
54
|
+
url: MaybeRefOrGetter<string | null | undefined>,
|
|
55
|
+
options: FakeApiDataOptions = {},
|
|
56
|
+
) {
|
|
57
|
+
const data = ref<T | null>(null);
|
|
58
|
+
const error = ref<unknown>(null);
|
|
59
|
+
const status = ref<"idle" | "pending" | "success" | "error">("idle");
|
|
60
|
+
const request = () => ({ url: toValue(url), query: toValue(options.query) });
|
|
61
|
+
async function refresh(): Promise<void> {
|
|
62
|
+
const { url: target, query } = request();
|
|
63
|
+
if (target == null) return;
|
|
64
|
+
const read = query ? { url: target, query } : { url: target };
|
|
65
|
+
reads.push(read);
|
|
66
|
+
status.value = "pending";
|
|
67
|
+
try {
|
|
68
|
+
// Each answer is a new value, as a parsed response is.
|
|
69
|
+
data.value = structuredClone(await backend(read)) as T;
|
|
70
|
+
error.value = null;
|
|
71
|
+
status.value = "success";
|
|
72
|
+
} catch (failure) {
|
|
73
|
+
error.value = failure;
|
|
74
|
+
status.value = "error";
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
watch(request, () => void refresh(), { deep: true, immediate: true });
|
|
78
|
+
if (options.refreshOnPageRefresh !== false) pageReads.add(refresh);
|
|
79
|
+
return {
|
|
80
|
+
data,
|
|
81
|
+
error,
|
|
82
|
+
status,
|
|
83
|
+
pending: computed(() => status.value === "pending"),
|
|
84
|
+
placeholder: ref(false),
|
|
85
|
+
refresh,
|
|
86
|
+
execute: refresh,
|
|
87
|
+
clear: () => undefined,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
interface FakePagedOptions<TPage> {
|
|
92
|
+
nextCursor: (page: TPage, pages: TPage[]) => number | null | undefined;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
type PagedContext = {
|
|
96
|
+
$authFetch: typeof authFetch;
|
|
97
|
+
cursor: number | undefined;
|
|
98
|
+
signal: AbortSignal;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
/** `useApiPagedData` as the renderer runs it: one key, the pages it loaded, all read again on a refresh. */
|
|
102
|
+
function useApiPagedData<TPage>(
|
|
103
|
+
key: MaybeRefOrGetter<string>,
|
|
104
|
+
handler: (context: PagedContext) => Promise<TPage>,
|
|
105
|
+
options: FakePagedOptions<TPage>,
|
|
106
|
+
) {
|
|
107
|
+
const pages = ref<TPage[]>([]) as { value: TPage[] };
|
|
108
|
+
const next = ref<number | null>(null);
|
|
109
|
+
const error = ref<unknown>(null);
|
|
110
|
+
const status = ref<"idle" | "pending" | "success" | "error">("idle");
|
|
111
|
+
const loadingMore = ref(false);
|
|
112
|
+
const read = (cursor: number | undefined) =>
|
|
113
|
+
handler({
|
|
114
|
+
$authFetch: authFetch,
|
|
115
|
+
cursor,
|
|
116
|
+
signal: new AbortController().signal,
|
|
117
|
+
});
|
|
118
|
+
async function readPages(count: number): Promise<void> {
|
|
119
|
+
status.value = "pending";
|
|
120
|
+
const loaded: TPage[] = [];
|
|
121
|
+
let cursor: number | undefined;
|
|
122
|
+
try {
|
|
123
|
+
for (let index = 0; index < count; index++) {
|
|
124
|
+
const page = structuredClone(await read(cursor)) as TPage;
|
|
125
|
+
loaded.push(page);
|
|
126
|
+
const after = options.nextCursor(page, loaded) ?? null;
|
|
127
|
+
next.value = after;
|
|
128
|
+
if (after === null) break;
|
|
129
|
+
cursor = after;
|
|
130
|
+
}
|
|
131
|
+
pages.value = loaded;
|
|
132
|
+
error.value = null;
|
|
133
|
+
status.value = "success";
|
|
134
|
+
} catch (failure) {
|
|
135
|
+
error.value = failure;
|
|
136
|
+
status.value = "error";
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
const refresh = () => readPages(Math.max(pages.value.length, 1));
|
|
140
|
+
watch(
|
|
141
|
+
() => toValue(key),
|
|
142
|
+
() => void readPages(1),
|
|
143
|
+
{ immediate: true },
|
|
144
|
+
);
|
|
145
|
+
pageReads.add(refresh);
|
|
146
|
+
async function loadMore(): Promise<void> {
|
|
147
|
+
if (next.value === null) return;
|
|
148
|
+
loadingMore.value = true;
|
|
149
|
+
try {
|
|
150
|
+
const page = structuredClone(await read(next.value)) as TPage;
|
|
151
|
+
pages.value = [...pages.value, page];
|
|
152
|
+
next.value = options.nextCursor(page, pages.value) ?? null;
|
|
153
|
+
} catch (failure) {
|
|
154
|
+
error.value = failure;
|
|
155
|
+
} finally {
|
|
156
|
+
loadingMore.value = false;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return {
|
|
160
|
+
pages,
|
|
161
|
+
error,
|
|
162
|
+
status,
|
|
163
|
+
pending: computed(() => status.value === "pending"),
|
|
164
|
+
hasMore: computed(() => next.value !== null),
|
|
165
|
+
loadingMore,
|
|
166
|
+
loadMore,
|
|
167
|
+
refresh,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const Stub = (name: string) =>
|
|
172
|
+
defineComponent({
|
|
173
|
+
name,
|
|
174
|
+
inheritAttrs: false,
|
|
175
|
+
setup:
|
|
176
|
+
(_, { slots }) =>
|
|
177
|
+
() =>
|
|
178
|
+
h("div", { "data-stub": name }, slots.default?.()),
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
const GLOBAL_STUBS = Object.fromEntries(
|
|
182
|
+
[
|
|
183
|
+
"DmsCard",
|
|
184
|
+
"DmsEmptyState",
|
|
185
|
+
"DmsMasterDetail",
|
|
186
|
+
"DmsSegmented",
|
|
187
|
+
"DmsKeyValueList",
|
|
188
|
+
"DmsIconWell",
|
|
189
|
+
"DmsEyebrow",
|
|
190
|
+
"DmsCodeSnippet",
|
|
191
|
+
"UButton",
|
|
192
|
+
"UInput",
|
|
193
|
+
"USkeleton",
|
|
194
|
+
].map((name) => [name, Stub(name)]),
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
const unavailable = () =>
|
|
198
|
+
Object.assign(new Error("Service Unavailable"), {
|
|
199
|
+
statusCode: 503,
|
|
200
|
+
data: { message: "The assistant is not running" },
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
const ROW = {
|
|
204
|
+
id: "act-1",
|
|
205
|
+
timestampMs: Date.UTC(2026, 9, 15, 10),
|
|
206
|
+
tool: "Edit",
|
|
207
|
+
target: "src/pages/home.ts",
|
|
208
|
+
agent: "claude",
|
|
209
|
+
allowedBy: "approved",
|
|
210
|
+
result: "done",
|
|
211
|
+
conversationId: "conv-1",
|
|
212
|
+
conversationTitle: "Rename the home page",
|
|
213
|
+
isReadOnly: false,
|
|
214
|
+
isAutoFix: false,
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
const summary = (id: string, number: number) => ({
|
|
218
|
+
id,
|
|
219
|
+
number,
|
|
220
|
+
title: `Change ${number}`,
|
|
221
|
+
state: "applied",
|
|
222
|
+
createdAtMs: Date.UTC(2026, 9, 15, 10),
|
|
223
|
+
added: 1,
|
|
224
|
+
removed: 0,
|
|
225
|
+
scope: "safe",
|
|
226
|
+
agent: "claude",
|
|
227
|
+
files: [],
|
|
228
|
+
isAutoFix: false,
|
|
229
|
+
approvalsNeeded: 0,
|
|
230
|
+
builderOps: 0,
|
|
231
|
+
typecheck: "passed",
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
beforeEach(() => {
|
|
235
|
+
reads.length = 0;
|
|
236
|
+
authFetch.mockReset();
|
|
237
|
+
route.query = {};
|
|
238
|
+
pageReads.clear();
|
|
239
|
+
refreshPageBlocks.mockClear();
|
|
240
|
+
vi.stubGlobal("useApiData", useApiData);
|
|
241
|
+
vi.stubGlobal("useApiPagedData", useApiPagedData);
|
|
242
|
+
vi.stubGlobal("refreshPageBlocks", refreshPageBlocks);
|
|
243
|
+
vi.stubGlobal("useAuthFetch", () => ({ $authFetch: authFetch }));
|
|
244
|
+
vi.stubGlobal("useI18n", () => ({
|
|
245
|
+
t: (key: string) => key,
|
|
246
|
+
te: () => false,
|
|
247
|
+
}));
|
|
248
|
+
vi.stubGlobal("useRegionalFormat", () => ({
|
|
249
|
+
dateTimeFormat: (options: Intl.DateTimeFormatOptions) =>
|
|
250
|
+
new Intl.DateTimeFormat("en-GB", { ...options, timeZone: "UTC" }),
|
|
251
|
+
}));
|
|
252
|
+
vi.stubGlobal("useToast", () => ({ add: vi.fn() }));
|
|
253
|
+
vi.stubGlobal("useConfirmFrom", () => ({ confirmFrom: vi.fn() }));
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
afterEach(() => {
|
|
257
|
+
vi.unstubAllGlobals();
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
const mount = (component: Parameters<typeof shallowMount>[0], props = {}) =>
|
|
261
|
+
shallowMount(component, {
|
|
262
|
+
props,
|
|
263
|
+
global: { stubs: { ...GLOBAL_STUBS, UnavailableState: false } },
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
describe("activity detail", () => {
|
|
267
|
+
it("reads the entry its row names with the page, not with $authFetch", async () => {
|
|
268
|
+
backend = () => ({ ...ROW, args: { file_path: "src/pages/home.ts" } });
|
|
269
|
+
const wrapper = mount(ActivityDetail, { rowData: ROW });
|
|
270
|
+
await flushPromises();
|
|
271
|
+
expect(reads).toEqual([{ url: "/ai/activity/act-1" }]);
|
|
272
|
+
expect(authFetch).not.toHaveBeenCalled();
|
|
273
|
+
expect(wrapper.findComponent(UnavailableState).exists()).toBe(false);
|
|
274
|
+
expect(wrapper.find('[data-stub="USkeleton"]').exists()).toBe(false);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it("says the assistant is not running on a 503, and reads again on retry", async () => {
|
|
278
|
+
backend = () => {
|
|
279
|
+
throw unavailable();
|
|
280
|
+
};
|
|
281
|
+
const wrapper = mount(ActivityDetail, { rowData: ROW });
|
|
282
|
+
await flushPromises();
|
|
283
|
+
const state = wrapper.findComponent(UnavailableState);
|
|
284
|
+
expect(state.props("failure")).toBe("unavailable");
|
|
285
|
+
backend = () => ROW;
|
|
286
|
+
state.vm.$emit("retry");
|
|
287
|
+
await flushPromises();
|
|
288
|
+
expect(reads).toHaveLength(2);
|
|
289
|
+
expect(wrapper.findComponent(UnavailableState).exists()).toBe(false);
|
|
290
|
+
});
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
describe("changes view", () => {
|
|
294
|
+
const PAGE = { results: [summary("cs-1", 1), summary("cs-2", 2)], total: 3 };
|
|
295
|
+
const NEXT_PAGE = { results: [summary("cs-3", 3)], total: 3 };
|
|
296
|
+
|
|
297
|
+
function changesBackend({ url, query }: Read): unknown {
|
|
298
|
+
if (url === "/ai/status") return { checkpointsAvailable: true };
|
|
299
|
+
if (url === "/ai/changes") return query?.offset ? NEXT_PAGE : PAGE;
|
|
300
|
+
return { changeSet: summary("cs-1", 1), files: [] };
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** The paged list's requests, which go through the handler's `$authFetch`. */
|
|
304
|
+
function listReads(): unknown[] {
|
|
305
|
+
return authFetch.mock.calls.map(([url, options]) => [
|
|
306
|
+
url,
|
|
307
|
+
(options as { query: unknown }).query,
|
|
308
|
+
]);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
beforeEach(() => {
|
|
312
|
+
authFetch.mockImplementation(async (url: string, options: Read) =>
|
|
313
|
+
changesBackend({ url, query: options.query }),
|
|
314
|
+
);
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
it("reads the status and the first page with the page, and opens the set the URL names", async () => {
|
|
318
|
+
backend = changesBackend;
|
|
319
|
+
route.query = { set: "cs-2" };
|
|
320
|
+
const wrapper = mount(ChangesView);
|
|
321
|
+
await flushPromises();
|
|
322
|
+
expect(reads).toEqual([{ url: "/ai/status" }]);
|
|
323
|
+
expect(listReads()).toEqual([["/ai/changes", { offset: 0, limit: 30 }]]);
|
|
324
|
+
expect(wrapper.findComponent(ChangeSetPanel).props("changeSetId")).toBe(
|
|
325
|
+
"cs-2",
|
|
326
|
+
);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it("selects the first set when the URL names none", async () => {
|
|
330
|
+
backend = changesBackend;
|
|
331
|
+
const wrapper = mount(ChangesView);
|
|
332
|
+
await flushPromises();
|
|
333
|
+
expect(wrapper.findComponent(ChangeSetPanel).props("changeSetId")).toBe(
|
|
334
|
+
"cs-1",
|
|
335
|
+
);
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
it("appends the next page on Load more, and reads every loaded page again on a page refresh", async () => {
|
|
339
|
+
backend = changesBackend;
|
|
340
|
+
const wrapper = mount(ChangesView);
|
|
341
|
+
await flushPromises();
|
|
342
|
+
const loadMoreButton = () =>
|
|
343
|
+
wrapper
|
|
344
|
+
.findAllComponents({ name: "UButton" })
|
|
345
|
+
.find(
|
|
346
|
+
(button) =>
|
|
347
|
+
button.vm.$attrs.label === "dms_ai.views.common.load_more",
|
|
348
|
+
);
|
|
349
|
+
loadMoreButton()!.vm.$emit("click");
|
|
350
|
+
await flushPromises();
|
|
351
|
+
expect(listReads().at(-1)).toEqual([
|
|
352
|
+
"/ai/changes",
|
|
353
|
+
{ offset: 2, limit: 30 },
|
|
354
|
+
]);
|
|
355
|
+
const items = () =>
|
|
356
|
+
(
|
|
357
|
+
wrapper.findComponent({ name: "DmsMasterDetail" }).vm.$attrs.items as {
|
|
358
|
+
value: string;
|
|
359
|
+
}[]
|
|
360
|
+
).map((item) => item.value);
|
|
361
|
+
expect(items()).toEqual(["cs-1", "cs-2", "cs-3"]);
|
|
362
|
+
expect(loadMoreButton()).toBeUndefined();
|
|
363
|
+
|
|
364
|
+
// A change set undone in the panel refreshes the page, which reads the
|
|
365
|
+
// pages loaded again rather than dropping the second one.
|
|
366
|
+
authFetch.mockClear();
|
|
367
|
+
await refreshPageBlocks();
|
|
368
|
+
await flushPromises();
|
|
369
|
+
expect(listReads()).toEqual([
|
|
370
|
+
["/ai/changes", { offset: 0, limit: 30 }],
|
|
371
|
+
["/ai/changes", { offset: 2, limit: 30 }],
|
|
372
|
+
]);
|
|
373
|
+
expect(items()).toEqual(["cs-1", "cs-2", "cs-3"]);
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
it("reads the filtered first page when the scope changes", async () => {
|
|
377
|
+
backend = changesBackend;
|
|
378
|
+
const wrapper = mount(ChangesView);
|
|
379
|
+
await flushPromises();
|
|
380
|
+
wrapper
|
|
381
|
+
.findComponent({ name: "DmsSegmented" })
|
|
382
|
+
.vm.$emit("update:modelValue", "vibe");
|
|
383
|
+
await flushPromises();
|
|
384
|
+
expect(listReads().at(-1)).toEqual([
|
|
385
|
+
"/ai/changes",
|
|
386
|
+
{ offset: 0, limit: 30, scope: "vibe", filter_scope: "is:vibe" },
|
|
387
|
+
]);
|
|
388
|
+
});
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
describe("change set panel", () => {
|
|
392
|
+
it("refreshes the page once a set is redone, instead of reading on its own", async () => {
|
|
393
|
+
backend = () => ({
|
|
394
|
+
changeSet: { ...summary("cs-1", 1), state: "undone" },
|
|
395
|
+
files: [],
|
|
396
|
+
});
|
|
397
|
+
authFetch.mockResolvedValue({});
|
|
398
|
+
const wrapper = mount(ChangeSetPanel, { changeSetId: "cs-1" });
|
|
399
|
+
await flushPromises();
|
|
400
|
+
expect(reads).toEqual([{ url: "/ai/changes/cs-1" }]);
|
|
401
|
+
const redo = wrapper
|
|
402
|
+
.findAllComponents({ name: "UButton" })
|
|
403
|
+
.find((button) => button.vm.$attrs.label === "dms_ai.views.changes.redo");
|
|
404
|
+
redo!.vm.$emit("click");
|
|
405
|
+
await flushPromises();
|
|
406
|
+
expect(authFetch).toHaveBeenCalledWith("/ai/changes/cs-1/redo", {
|
|
407
|
+
method: "POST",
|
|
408
|
+
body: {},
|
|
409
|
+
});
|
|
410
|
+
expect(refreshPageBlocks).toHaveBeenCalledTimes(1);
|
|
411
|
+
expect(reads).toEqual([
|
|
412
|
+
{ url: "/ai/changes/cs-1" },
|
|
413
|
+
{ url: "/ai/changes/cs-1" },
|
|
414
|
+
]);
|
|
415
|
+
});
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
describe("change set actions", () => {
|
|
419
|
+
it("stay busy until the page refresh a redo asks for is done", async () => {
|
|
420
|
+
backend = () => ({
|
|
421
|
+
changeSet: { ...summary("cs-1", 1), state: "undone" },
|
|
422
|
+
files: [],
|
|
423
|
+
});
|
|
424
|
+
authFetch.mockResolvedValue({});
|
|
425
|
+
let finishRefresh: () => void = () => undefined;
|
|
426
|
+
refreshPageBlocks.mockImplementationOnce(
|
|
427
|
+
() => new Promise<void>((resolve) => (finishRefresh = resolve)),
|
|
428
|
+
);
|
|
429
|
+
const wrapper = mount(ChangeSetPanel, { changeSetId: "cs-1" });
|
|
430
|
+
await flushPromises();
|
|
431
|
+
const redo = () =>
|
|
432
|
+
wrapper
|
|
433
|
+
.findAllComponents({ name: "UButton" })
|
|
434
|
+
.find(
|
|
435
|
+
(button) => button.vm.$attrs.label === "dms_ai.views.changes.redo",
|
|
436
|
+
)!;
|
|
437
|
+
redo().vm.$emit("click");
|
|
438
|
+
await flushPromises();
|
|
439
|
+
expect(refreshPageBlocks).toHaveBeenCalledTimes(1);
|
|
440
|
+
expect(redo().vm.$attrs.loading).toBe(true);
|
|
441
|
+
finishRefresh();
|
|
442
|
+
await flushPromises();
|
|
443
|
+
expect(redo().vm.$attrs.loading).toBe(false);
|
|
444
|
+
});
|
|
445
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { createSSRApp, defineComponent, h, nextTick, shallowRef } from "vue";
|
|
4
|
+
import { renderToString } from "vue/server-renderer";
|
|
5
|
+
import type { AssistantSession } from "../../app/runtime/assistant-session";
|
|
6
|
+
import { ASSISTANT_SESSION_KEY } from "../../app/runtime/assistant-session";
|
|
7
|
+
import { useAssistant } from "../../app/views/composables/useAssistant";
|
|
8
|
+
|
|
9
|
+
vi.mock("#dms/frontend-module", () => ({
|
|
10
|
+
useDmsRouter: () => ({ push: vi.fn() }),
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
// A view offering an assistant button, as the Changes empty state does.
|
|
14
|
+
const View = defineComponent({
|
|
15
|
+
setup() {
|
|
16
|
+
const assistant = useAssistant();
|
|
17
|
+
return () =>
|
|
18
|
+
h("div", [assistant.isAvailable ? h("button", "Open assistant") : null]);
|
|
19
|
+
},
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
function appWith(session: AssistantSession | null) {
|
|
23
|
+
const app = createSSRApp(View);
|
|
24
|
+
app.provide(ASSISTANT_SESSION_KEY, shallowRef(session));
|
|
25
|
+
return app;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
afterEach(() => {
|
|
29
|
+
document.body.innerHTML = "";
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe("useAssistant", () => {
|
|
33
|
+
it("hydrates what the server rendered when the session started first, then offers the assistant", async () => {
|
|
34
|
+
const html = await renderToString(appWith(null));
|
|
35
|
+
const root = document.createElement("div");
|
|
36
|
+
root.innerHTML = html;
|
|
37
|
+
document.body.append(root);
|
|
38
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
39
|
+
const error = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
40
|
+
|
|
41
|
+
// The tab's probe answered before this block hydrated.
|
|
42
|
+
appWith({} as AssistantSession).mount(root);
|
|
43
|
+
const mismatches = [...warn.mock.calls, ...error.mock.calls]
|
|
44
|
+
.flat()
|
|
45
|
+
.filter((entry) => String(entry).includes("Hydration"));
|
|
46
|
+
warn.mockRestore();
|
|
47
|
+
error.mockRestore();
|
|
48
|
+
expect(mismatches).toEqual([]);
|
|
49
|
+
|
|
50
|
+
await nextTick();
|
|
51
|
+
expect(root.querySelector("button")?.textContent).toBe("Open assistant");
|
|
52
|
+
});
|
|
53
|
+
});
|
|
@@ -1,8 +1,18 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
1
2
|
import vue from "@vitejs/plugin-vue";
|
|
2
3
|
import { defineConfig } from "vitest/config";
|
|
3
4
|
|
|
4
5
|
export default defineConfig({
|
|
5
6
|
plugins: [vue()],
|
|
7
|
+
resolve: {
|
|
8
|
+
alias: {
|
|
9
|
+
// The renderer's runtime exists in the generated application only.
|
|
10
|
+
"#dms/frontend-module": resolve(
|
|
11
|
+
__dirname,
|
|
12
|
+
"tests/support/dms-frontend-module.ts",
|
|
13
|
+
),
|
|
14
|
+
},
|
|
15
|
+
},
|
|
6
16
|
test: {
|
|
7
17
|
// Most suites need no DOM, and setting jsdom up per file is the bulk of
|
|
8
18
|
// the run; the files that mount components opt in with a
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@antelopejs/dms-ai",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
"dependencies": {
|
|
55
55
|
"@antelopejs/interface-api": ">=0.0.14 <1.0.0",
|
|
56
56
|
"@antelopejs/interface-core": ">=0.1.1 <1.0.0",
|
|
57
|
-
"@antelopejs/interface-dms": ">=0.
|
|
57
|
+
"@antelopejs/interface-dms": ">=0.7.0 <1.0.0",
|
|
58
58
|
"@anthropic-ai/claude-agent-sdk": "^0.2.131",
|
|
59
59
|
"@anthropic-ai/claude-code": "^2.1.131",
|
|
60
60
|
"@modelcontextprotocol/sdk": "1.29.0",
|
|
@@ -1,89 +0,0 @@
|
|
|
1
|
-
import { ref, shallowRef, type Ref, type ShallowRef } from "vue";
|
|
2
|
-
import { HTTP_SERVICE_UNAVAILABLE } from "../constants";
|
|
3
|
-
|
|
4
|
-
/** Why a request failed: the sidecar is down (503), or anything else. */
|
|
5
|
-
export type RequestFailure = "unavailable" | "error";
|
|
6
|
-
|
|
7
|
-
export interface AiRequest<T> {
|
|
8
|
-
data: ShallowRef<T | null>;
|
|
9
|
-
isLoading: Ref<boolean>;
|
|
10
|
-
failure: Ref<RequestFailure | null>;
|
|
11
|
-
message: Ref<string>;
|
|
12
|
-
/**
|
|
13
|
-
* Runs `fetcher`, keeping the previous data while it loads; a run that a
|
|
14
|
-
* later one overtook is dropped.
|
|
15
|
-
*/
|
|
16
|
-
run: (fetcher: () => Promise<T>) => Promise<T | null>;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
interface FetchErrorResponse {
|
|
20
|
-
status?: number;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
interface FetchErrorBody {
|
|
24
|
-
message?: unknown;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
interface FetchErrorLike {
|
|
28
|
-
status?: number;
|
|
29
|
-
statusCode?: number;
|
|
30
|
-
response?: FetchErrorResponse;
|
|
31
|
-
data?: FetchErrorBody;
|
|
32
|
-
message?: string;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/** The HTTP status of a `$authFetch` rejection, if it got an answer. */
|
|
36
|
-
function errorStatus(error: unknown): number | undefined {
|
|
37
|
-
const candidate = error as FetchErrorLike | null;
|
|
38
|
-
return (
|
|
39
|
-
candidate?.status ?? candidate?.statusCode ?? candidate?.response?.status
|
|
40
|
-
);
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/** The server's `{ message }`, else the error's own text. */
|
|
44
|
-
export function errorMessage(error: unknown): string {
|
|
45
|
-
const candidate = error as FetchErrorLike | null;
|
|
46
|
-
const serverMessage = candidate?.data?.message;
|
|
47
|
-
if (typeof serverMessage === "string") return serverMessage;
|
|
48
|
-
return candidate?.message ?? String(error);
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
export function classifyFailure(error: unknown): RequestFailure {
|
|
52
|
-
return errorStatus(error) === HTTP_SERVICE_UNAVAILABLE
|
|
53
|
-
? "unavailable"
|
|
54
|
-
: "error";
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
/**
|
|
58
|
-
* One request's state for a view: its data, whether it loads, and whether a
|
|
59
|
-
* failure means "the assistant isn't running" (503) rather than an error.
|
|
60
|
-
*/
|
|
61
|
-
export function useAiRequest<T>(): AiRequest<T> {
|
|
62
|
-
const data = shallowRef<T | null>(null);
|
|
63
|
-
const isLoading = ref(false);
|
|
64
|
-
const failure = ref<RequestFailure | null>(null);
|
|
65
|
-
const message = ref("");
|
|
66
|
-
let latestRun = 0;
|
|
67
|
-
|
|
68
|
-
async function run(fetcher: () => Promise<T>): Promise<T | null> {
|
|
69
|
-
latestRun += 1;
|
|
70
|
-
const runId = latestRun;
|
|
71
|
-
isLoading.value = true;
|
|
72
|
-
try {
|
|
73
|
-
const result = await fetcher();
|
|
74
|
-
if (runId !== latestRun) return null;
|
|
75
|
-
data.value = result;
|
|
76
|
-
failure.value = null;
|
|
77
|
-
return result;
|
|
78
|
-
} catch (error) {
|
|
79
|
-
if (runId !== latestRun) return null;
|
|
80
|
-
failure.value = classifyFailure(error);
|
|
81
|
-
message.value = errorMessage(error);
|
|
82
|
-
return null;
|
|
83
|
-
} finally {
|
|
84
|
-
if (runId === latestRun) isLoading.value = false;
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
return { data, isLoading, failure, message, run };
|
|
89
|
-
}
|