setup-agent 0.0.3 → 0.0.5
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/LICENSE +21 -0
- package/README.md +11 -5
- package/dist/cli.d.ts +51 -46
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +81 -22
- package/dist/cli.js.map +1 -1
- package/dist/client.d.ts +3 -6
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +12 -4
- package/dist/client.js.map +1 -1
- package/dist/host.d.ts +7 -9
- package/dist/host.d.ts.map +1 -1
- package/dist/host.js +82 -75
- package/dist/host.js.map +1 -1
- package/dist/index.d.ts +7 -4
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2 -2
- package/dist/presets.d.ts +23 -25
- package/dist/presets.d.ts.map +1 -1
- package/dist/presets.js +4 -3
- package/dist/presets.js.map +1 -1
- package/dist/protocol.d.ts +279 -262
- package/dist/protocol.d.ts.map +1 -1
- package/dist/protocol.js +35 -4
- package/dist/protocol.js.map +1 -1
- package/package.json +20 -13
- package/src/cli.test.ts +615 -0
- package/src/cli.ts +118 -29
- package/src/client.test.ts +54 -0
- package/src/client.ts +12 -4
- package/src/host.test.ts +1030 -0
- package/src/host.ts +52 -9
- package/src/index.ts +3 -0
- package/src/presets.ts +4 -3
- package/src/protocol.test.ts +128 -0
- package/src/protocol.ts +103 -5
package/src/host.test.ts
ADDED
|
@@ -0,0 +1,1030 @@
|
|
|
1
|
+
import { describe, expect, it, onTestFinished, vi } from "vitest";
|
|
2
|
+
import { createTapRoot, flushTapSync, useResource } from "@assistant-ui/tap";
|
|
3
|
+
import { StatewireSendError } from "statewire";
|
|
4
|
+
import { CheckoutHost } from "./host";
|
|
5
|
+
import { isPresetId, presetInput } from "./presets";
|
|
6
|
+
import {
|
|
7
|
+
classifyChoiceAnswer,
|
|
8
|
+
currentPlan,
|
|
9
|
+
followedUpSinceProposal,
|
|
10
|
+
initialCheckoutState,
|
|
11
|
+
isAgentPresent,
|
|
12
|
+
openInputs,
|
|
13
|
+
parseChoiceAnswer,
|
|
14
|
+
parseModelAnswer,
|
|
15
|
+
planNeedsReview,
|
|
16
|
+
stepProgress,
|
|
17
|
+
} from "./protocol";
|
|
18
|
+
import type { Checkout } from "./protocol";
|
|
19
|
+
|
|
20
|
+
const mount = (restored?: unknown) => {
|
|
21
|
+
const root = createTapRoot(() => useResource(CheckoutHost(restored)));
|
|
22
|
+
flushTapSync(() => {});
|
|
23
|
+
return root.getValue();
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const seed = {
|
|
27
|
+
id: "c1",
|
|
28
|
+
products: [
|
|
29
|
+
{
|
|
30
|
+
slug: "assistant-ui",
|
|
31
|
+
name: "assistant-ui",
|
|
32
|
+
guide: "https://example.test/install.md?items=assistant-ui",
|
|
33
|
+
},
|
|
34
|
+
],
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const reason = async (promise: Promise<unknown>) => {
|
|
38
|
+
try {
|
|
39
|
+
await promise;
|
|
40
|
+
} catch (error) {
|
|
41
|
+
expect(error).toBeInstanceOf(StatewireSendError);
|
|
42
|
+
const { failure } = error as StatewireSendError;
|
|
43
|
+
if (failure.fate !== "rejected") throw new Error(failure.fate);
|
|
44
|
+
return failure.payload;
|
|
45
|
+
}
|
|
46
|
+
throw new Error("expected a rejection");
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const approvedHost = async () => {
|
|
50
|
+
const host = mount();
|
|
51
|
+
await host.commands["checkout/create"](seed);
|
|
52
|
+
await host.commands["agent/hello"]({ cwd: "/app" });
|
|
53
|
+
await host.commands["checkout/begin-plan"]();
|
|
54
|
+
await host.commands["agent/plan"]({ markdown: "## Steps\n1. Install" });
|
|
55
|
+
await host.commands["checkout/plan"]({ decision: "approve" });
|
|
56
|
+
return host;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
describe("CheckoutHost", () => {
|
|
60
|
+
it("creates the products once, with no steps or questions", async () => {
|
|
61
|
+
const host = mount();
|
|
62
|
+
await host.commands["checkout/create"](seed);
|
|
63
|
+
expect(host.state.status).toBe("waiting");
|
|
64
|
+
expect(host.state.products).toEqual(seed.products);
|
|
65
|
+
expect(host.state.steps).toEqual([]);
|
|
66
|
+
expect(openInputs(host.state)).toHaveLength(0);
|
|
67
|
+
expect(await reason(host.commands["checkout/create"](seed))).toEqual({
|
|
68
|
+
reason: "already-created",
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("refuses agent commands before creation and after close", async () => {
|
|
73
|
+
const host = mount();
|
|
74
|
+
expect(await reason(host.commands["agent/log"]({ text: "x" }))).toEqual({
|
|
75
|
+
reason: "not-created",
|
|
76
|
+
});
|
|
77
|
+
await host.commands["checkout/create"](seed);
|
|
78
|
+
await host.commands["checkout/begin-plan"]();
|
|
79
|
+
await host.commands["agent/ask"]({ prompt: "Where?" });
|
|
80
|
+
await host.commands["checkout/cancel"]();
|
|
81
|
+
expect(await reason(host.commands["agent/log"]({ text: "x" }))).toEqual({
|
|
82
|
+
reason: "closed",
|
|
83
|
+
});
|
|
84
|
+
expect(openInputs(host.state)).toHaveLength(0);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("keeps a connected agent waiting until the browser begins planning", async () => {
|
|
88
|
+
const host = mount();
|
|
89
|
+
expect(await reason(host.commands["checkout/begin-plan"]())).toEqual({
|
|
90
|
+
reason: "not-created",
|
|
91
|
+
});
|
|
92
|
+
await host.commands["checkout/create"](seed);
|
|
93
|
+
await host.commands["agent/hello"]({ cwd: "/app" });
|
|
94
|
+
await host.commands["agent/heartbeat"]();
|
|
95
|
+
await host.commands["agent/log"]({ text: "Ready when you are" });
|
|
96
|
+
expect(host.state.status).toBe("waiting");
|
|
97
|
+
expect(host.state.agent.cwd).toBe("/app");
|
|
98
|
+
expect(isAgentPresent(host.state)).toBe(true);
|
|
99
|
+
expect(
|
|
100
|
+
await reason(host.commands["agent/ask"]({ prompt: "Where?" })),
|
|
101
|
+
).toEqual({ reason: "planning-not-started" });
|
|
102
|
+
expect(
|
|
103
|
+
await reason(host.commands["agent/plan"]({ markdown: "Install" })),
|
|
104
|
+
).toEqual({ reason: "planning-not-started" });
|
|
105
|
+
const restored = mount(host.snapshot());
|
|
106
|
+
expect(restored.state.status).toBe("waiting");
|
|
107
|
+
await restored.commands["checkout/begin-plan"]();
|
|
108
|
+
await restored.commands["checkout/begin-plan"]();
|
|
109
|
+
expect(restored.state.status).toBe("planning");
|
|
110
|
+
await restored.commands["agent/plan"]({ markdown: "Install" });
|
|
111
|
+
await restored.commands["checkout/plan"]({ decision: "approve" });
|
|
112
|
+
await restored.commands["checkout/begin-plan"]();
|
|
113
|
+
expect(restored.state.status).toBe("installing");
|
|
114
|
+
await restored.commands["checkout/cancel"]();
|
|
115
|
+
expect(await reason(restored.commands["checkout/begin-plan"]())).toEqual({
|
|
116
|
+
reason: "closed",
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("replaces a closed setup on the next create and keeps the agent while it is present", async () => {
|
|
121
|
+
const host = mount();
|
|
122
|
+
await host.commands["agent/hello"]({ cwd: "/app", kind: "claude" });
|
|
123
|
+
await host.commands["checkout/create"](seed);
|
|
124
|
+
await host.commands["checkout/begin-plan"]();
|
|
125
|
+
await host.commands["agent/plan"]({ markdown: "## Steps\n1. Install" });
|
|
126
|
+
await host.commands["checkout/plan"]({ decision: "approve" });
|
|
127
|
+
await host.commands["agent/done"]({ summary: "Installed" });
|
|
128
|
+
await host.commands["checkout/finish"]();
|
|
129
|
+
await host.commands["agent/heartbeat"]();
|
|
130
|
+
expect(await reason(host.commands["checkout/create"](seed))).toEqual({
|
|
131
|
+
reason: "already-created",
|
|
132
|
+
});
|
|
133
|
+
const next = {
|
|
134
|
+
id: "c2",
|
|
135
|
+
products: [{ slug: "cloud", name: "Cloud" }],
|
|
136
|
+
instructions: "Use pnpm",
|
|
137
|
+
};
|
|
138
|
+
await host.commands["checkout/create"](next);
|
|
139
|
+
expect(host.state.id).toBe("c2");
|
|
140
|
+
expect(host.state.status).toBe("waiting");
|
|
141
|
+
expect(host.state.completion).toBeUndefined();
|
|
142
|
+
expect(host.state.products).toEqual(next.products);
|
|
143
|
+
expect(host.state.instructions).toBe("Use pnpm");
|
|
144
|
+
expect(host.state.plans).toEqual([]);
|
|
145
|
+
expect(host.state.log).toEqual([]);
|
|
146
|
+
expect(host.state.agent.cwd).toBe("/app");
|
|
147
|
+
expect(host.state.agent.kind).toBe("claude");
|
|
148
|
+
expect(isAgentPresent(host.state)).toBe(true);
|
|
149
|
+
await host.commands["agent/bye"]();
|
|
150
|
+
await host.commands["checkout/create"]({ ...next, id: "c3" });
|
|
151
|
+
expect(host.state.agent).toEqual(initialCheckoutState().agent);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("restores a snapshot and drops one from an older version", async () => {
|
|
155
|
+
const first = mount();
|
|
156
|
+
await first.commands["checkout/create"](seed);
|
|
157
|
+
const restored = mount(first.snapshot());
|
|
158
|
+
expect(restored.state.products).toHaveLength(1);
|
|
159
|
+
expect(restored.state.createdAt).toBe(first.state.createdAt);
|
|
160
|
+
const stale = mount({ ...(first.snapshot() as object), version: 1 });
|
|
161
|
+
expect(stale.state.createdAt).toBeNull();
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
describe("plans", () => {
|
|
166
|
+
it("refuses steps until the user approves a plan", async () => {
|
|
167
|
+
const host = mount();
|
|
168
|
+
await host.commands["checkout/create"](seed);
|
|
169
|
+
await host.commands["checkout/begin-plan"]();
|
|
170
|
+
await host.commands["agent/hello"]({});
|
|
171
|
+
expect(
|
|
172
|
+
await reason(host.commands["agent/add-step"]({ title: "Install" })),
|
|
173
|
+
).toEqual({ reason: "plan-required" });
|
|
174
|
+
expect(
|
|
175
|
+
await reason(host.commands["checkout/plan"]({ decision: "approve" })),
|
|
176
|
+
).toEqual({ reason: "no-plan" });
|
|
177
|
+
expect(
|
|
178
|
+
await reason(host.commands["agent/plan"]({ markdown: " " })),
|
|
179
|
+
).toEqual({ reason: "empty-plan" });
|
|
180
|
+
|
|
181
|
+
expect(await host.commands["agent/plan"]({ markdown: "## Steps" })).toEqual(
|
|
182
|
+
{ revision: 1 },
|
|
183
|
+
);
|
|
184
|
+
expect(planNeedsReview(host.state)).toBe(true);
|
|
185
|
+
expect(
|
|
186
|
+
await reason(host.commands["agent/add-step"]({ title: "Install" })),
|
|
187
|
+
).toEqual({ reason: "plan-required" });
|
|
188
|
+
|
|
189
|
+
await host.commands["checkout/plan"]({ decision: "approve" });
|
|
190
|
+
expect(host.state.status).toBe("installing");
|
|
191
|
+
expect(currentPlan(host.state)?.status).toBe("approved");
|
|
192
|
+
expect(planNeedsReview(host.state)).toBe(false);
|
|
193
|
+
expect(
|
|
194
|
+
await reason(host.commands["checkout/plan"]({ decision: "approve" })),
|
|
195
|
+
).toEqual({ reason: "plan-decided" });
|
|
196
|
+
expect(
|
|
197
|
+
await reason(host.commands["agent/plan"]({ markdown: "again" })),
|
|
198
|
+
).toEqual({ reason: "plan-decided" });
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("keeps every revision and the feedback that asked for the next", async () => {
|
|
202
|
+
const host = mount();
|
|
203
|
+
await host.commands["checkout/create"](seed);
|
|
204
|
+
await host.commands["checkout/begin-plan"]();
|
|
205
|
+
await host.commands["agent/hello"]({});
|
|
206
|
+
await host.commands["agent/plan"]({ markdown: "v1" });
|
|
207
|
+
expect(
|
|
208
|
+
await reason(
|
|
209
|
+
host.commands["checkout/plan"]({ decision: "revise", feedback: " " }),
|
|
210
|
+
),
|
|
211
|
+
).toEqual({ reason: "feedback-required" });
|
|
212
|
+
await host.commands["checkout/plan"]({
|
|
213
|
+
decision: "revise",
|
|
214
|
+
feedback: "Use pnpm",
|
|
215
|
+
});
|
|
216
|
+
expect(host.state.status).toBe("planning");
|
|
217
|
+
expect(currentPlan(host.state)).toMatchObject({
|
|
218
|
+
revision: 1,
|
|
219
|
+
status: "changes-requested",
|
|
220
|
+
feedback: "Use pnpm",
|
|
221
|
+
});
|
|
222
|
+
expect(await host.commands["agent/plan"]({ markdown: "v2" })).toEqual({
|
|
223
|
+
revision: 2,
|
|
224
|
+
});
|
|
225
|
+
expect(host.state.plans.map((plan) => plan.status)).toEqual([
|
|
226
|
+
"changes-requested",
|
|
227
|
+
"proposed",
|
|
228
|
+
]);
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
describe("steps", () => {
|
|
233
|
+
it("records completed tasks once in chat and preserves them when restored", async () => {
|
|
234
|
+
const host = await approvedHost();
|
|
235
|
+
const { stepId } = await host.commands["agent/add-step"]({
|
|
236
|
+
title: "Install packages",
|
|
237
|
+
active: true,
|
|
238
|
+
});
|
|
239
|
+
await host.commands["agent/log"]({ text: "Installing" });
|
|
240
|
+
await host.commands["agent/step"]({
|
|
241
|
+
stepId,
|
|
242
|
+
status: "done",
|
|
243
|
+
note: "Packages installed.",
|
|
244
|
+
});
|
|
245
|
+
await host.commands["agent/step"]({ stepId, status: "done" });
|
|
246
|
+
await host.commands["agent/log"]({ text: "Checking the route" });
|
|
247
|
+
expect(host.state.log.map((entry) => entry.text)).toEqual([
|
|
248
|
+
"Installing",
|
|
249
|
+
"Completed: Install packages\n\nPackages installed.",
|
|
250
|
+
"Checking the route",
|
|
251
|
+
]);
|
|
252
|
+
expect(host.state.log[1]).toMatchObject({
|
|
253
|
+
role: "agent",
|
|
254
|
+
stepId,
|
|
255
|
+
at: expect.any(Number),
|
|
256
|
+
});
|
|
257
|
+
expect(mount(host.snapshot()).state.log).toEqual(host.state.log);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it("reports skipped tasks and tasks finished by done without duplicating earlier completions", async () => {
|
|
261
|
+
const host = await approvedHost();
|
|
262
|
+
const first = await host.commands["agent/add-step"]({
|
|
263
|
+
title: "Install packages",
|
|
264
|
+
});
|
|
265
|
+
await host.commands["agent/step"]({
|
|
266
|
+
stepId: first.stepId,
|
|
267
|
+
status: "skipped",
|
|
268
|
+
note: "Already installed.",
|
|
269
|
+
});
|
|
270
|
+
await host.commands["agent/add-step"]({
|
|
271
|
+
title: "Wire the route",
|
|
272
|
+
active: true,
|
|
273
|
+
});
|
|
274
|
+
await host.commands["agent/add-step"]({ title: "Check types" });
|
|
275
|
+
await host.commands["agent/done"]();
|
|
276
|
+
expect(host.state.log.map((entry) => entry.text)).toEqual([
|
|
277
|
+
"Skipped: Install packages\n\nAlready installed.",
|
|
278
|
+
"Completed: Wire the route",
|
|
279
|
+
"Completed: Check types",
|
|
280
|
+
"Everything is installed.",
|
|
281
|
+
]);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it("lets the agent declare, start and finish its own steps", async () => {
|
|
285
|
+
const host = await approvedHost();
|
|
286
|
+
const first = await host.commands["agent/add-step"]({
|
|
287
|
+
title: "Install packages",
|
|
288
|
+
detail: "pnpm add",
|
|
289
|
+
product: "assistant-ui",
|
|
290
|
+
active: true,
|
|
291
|
+
});
|
|
292
|
+
const second = await host.commands["agent/add-step"]({
|
|
293
|
+
title: "Wire the route",
|
|
294
|
+
});
|
|
295
|
+
expect([first, second]).toEqual([{ stepId: "s1" }, { stepId: "s2" }]);
|
|
296
|
+
expect(host.state.steps.map((step) => step.status)).toEqual([
|
|
297
|
+
"active",
|
|
298
|
+
"pending",
|
|
299
|
+
]);
|
|
300
|
+
await host.commands["agent/log"]({ text: "Adding packages" });
|
|
301
|
+
expect(host.state.log.at(-1)?.stepId).toBe("s1");
|
|
302
|
+
await host.commands["agent/step"]({
|
|
303
|
+
stepId: "s1",
|
|
304
|
+
status: "done",
|
|
305
|
+
note: "pnpm add ran",
|
|
306
|
+
});
|
|
307
|
+
expect(stepProgress(host.state)).toEqual({ done: 1, total: 2 });
|
|
308
|
+
expect(
|
|
309
|
+
await reason(
|
|
310
|
+
host.commands["agent/step"]({ stepId: "nope", status: "done" }),
|
|
311
|
+
),
|
|
312
|
+
).toEqual({ reason: "unknown-step" });
|
|
313
|
+
await host.commands["agent/ask"]({ prompt: "Port?", stepId: "s2" });
|
|
314
|
+
expect(await reason(host.commands["checkout/finish"]())).toEqual({
|
|
315
|
+
reason: "finish-not-proposed",
|
|
316
|
+
});
|
|
317
|
+
await host.commands["agent/done"]({ summary: " Installed the thread. " });
|
|
318
|
+
expect(host.state.status).toBe("installing");
|
|
319
|
+
expect(host.state.log.at(-1)?.text).toBe("Installed the thread.");
|
|
320
|
+
const proposedAt = host.state.completion?.proposedAt;
|
|
321
|
+
expect(proposedAt).toBeTypeOf("number");
|
|
322
|
+
expect(host.state.steps.map((step) => step.status)).toEqual([
|
|
323
|
+
"done",
|
|
324
|
+
"done",
|
|
325
|
+
]);
|
|
326
|
+
expect(openInputs(host.state)).toHaveLength(1);
|
|
327
|
+
|
|
328
|
+
await host.commands["checkout/message"]({ text: "Add dark mode too" });
|
|
329
|
+
expect(host.state.completion?.proposedAt).toBe(proposedAt);
|
|
330
|
+
await host.commands["agent/add-step"]({ title: "Dark mode", active: true });
|
|
331
|
+
await host.commands["agent/done"]();
|
|
332
|
+
|
|
333
|
+
await host.commands["checkout/finish"]();
|
|
334
|
+
expect(host.state.status).toBe("done");
|
|
335
|
+
expect(host.state.log.at(-1)?.text).toBe("Setup complete.");
|
|
336
|
+
expect(openInputs(host.state)).toHaveLength(0);
|
|
337
|
+
});
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
describe("preview", () => {
|
|
341
|
+
it("keeps a loopback dev server address on the proposal", async () => {
|
|
342
|
+
const host = await approvedHost();
|
|
343
|
+
await host.commands["agent/done"]({
|
|
344
|
+
preview: " http://localhost:3000/chat ",
|
|
345
|
+
});
|
|
346
|
+
expect(host.state.completion?.preview).toBe("http://localhost:3000/chat");
|
|
347
|
+
await host.commands["agent/done"]();
|
|
348
|
+
expect(host.state.completion?.preview).toBeUndefined();
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
it.each([
|
|
352
|
+
"https://example.com",
|
|
353
|
+
"http://localhost.example.com:3000",
|
|
354
|
+
"http://user:pass@localhost:3000",
|
|
355
|
+
"javascript:alert(1)",
|
|
356
|
+
"localhost:3000",
|
|
357
|
+
])("refuses %s as a preview", async (preview) => {
|
|
358
|
+
const host = await approvedHost();
|
|
359
|
+
expect(await reason(host.commands["agent/done"]({ preview }))).toEqual({
|
|
360
|
+
reason: "invalid-preview",
|
|
361
|
+
});
|
|
362
|
+
expect(host.state.completion).toBeUndefined();
|
|
363
|
+
});
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
describe("inputs", () => {
|
|
367
|
+
it("adds a product the agent proposed once the user accepts", async () => {
|
|
368
|
+
const host = mount();
|
|
369
|
+
await host.commands["checkout/create"]({
|
|
370
|
+
id: "c1",
|
|
371
|
+
products: [{ slug: "attachments", name: "Attachments" }],
|
|
372
|
+
});
|
|
373
|
+
await host.commands["checkout/begin-plan"]();
|
|
374
|
+
await host.commands["agent/hello"]({});
|
|
375
|
+
expect(
|
|
376
|
+
await reason(
|
|
377
|
+
host.commands["agent/ask"]({ kind: "product", prompt: "Add it?" }),
|
|
378
|
+
),
|
|
379
|
+
).toEqual({ reason: "invalid-input" });
|
|
380
|
+
expect(
|
|
381
|
+
await reason(
|
|
382
|
+
host.commands["agent/ask"]({
|
|
383
|
+
kind: "product",
|
|
384
|
+
prompt: "Add it?",
|
|
385
|
+
product: "attachments",
|
|
386
|
+
}),
|
|
387
|
+
),
|
|
388
|
+
).toEqual({ reason: "invalid-input" });
|
|
389
|
+
const { inputId } = await host.commands["agent/ask"]({
|
|
390
|
+
kind: "product",
|
|
391
|
+
prompt: "assistant-ui is not installed yet. Add it?",
|
|
392
|
+
product: "assistant-ui",
|
|
393
|
+
});
|
|
394
|
+
expect(
|
|
395
|
+
await reason(host.commands["checkout/answer"]({ inputId, answer: "ok" })),
|
|
396
|
+
).toEqual({ reason: "invalid-answer" });
|
|
397
|
+
expect(
|
|
398
|
+
await reason(
|
|
399
|
+
host.commands["checkout/add-product"]({
|
|
400
|
+
inputId,
|
|
401
|
+
product: { slug: "cloud", name: "Cloud" },
|
|
402
|
+
}),
|
|
403
|
+
),
|
|
404
|
+
).toEqual({ reason: "invalid-answer" });
|
|
405
|
+
await host.commands["checkout/add-product"]({
|
|
406
|
+
inputId,
|
|
407
|
+
product: { ...seed.products[0]! },
|
|
408
|
+
});
|
|
409
|
+
expect(host.state.products.map((product) => product.slug)).toEqual([
|
|
410
|
+
"attachments",
|
|
411
|
+
"assistant-ui",
|
|
412
|
+
]);
|
|
413
|
+
expect(host.state.products[1]?.guide).toBe(seed.products[0]!.guide);
|
|
414
|
+
expect(host.state.inputs[0]).toMatchObject({
|
|
415
|
+
status: "answered",
|
|
416
|
+
answer: "added",
|
|
417
|
+
});
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
it("answers text with a note and closes the input", async () => {
|
|
421
|
+
const host = mount();
|
|
422
|
+
await host.commands["checkout/create"](seed);
|
|
423
|
+
await host.commands["checkout/begin-plan"]();
|
|
424
|
+
await host.commands["agent/hello"]({});
|
|
425
|
+
const asked = await host.commands["agent/ask"]({ prompt: "Where?" });
|
|
426
|
+
expect(
|
|
427
|
+
await reason(
|
|
428
|
+
host.commands["checkout/answer"]({
|
|
429
|
+
inputId: asked.inputId,
|
|
430
|
+
answer: " ",
|
|
431
|
+
}),
|
|
432
|
+
),
|
|
433
|
+
).toEqual({ reason: "invalid-answer" });
|
|
434
|
+
await host.commands["checkout/answer"]({
|
|
435
|
+
inputId: asked.inputId,
|
|
436
|
+
answer: "/app",
|
|
437
|
+
note: " use the web folder ",
|
|
438
|
+
});
|
|
439
|
+
expect(host.state.inputs[0]).toMatchObject({
|
|
440
|
+
status: "answered",
|
|
441
|
+
answer: "/app",
|
|
442
|
+
note: "use the web folder",
|
|
443
|
+
});
|
|
444
|
+
expect(
|
|
445
|
+
await reason(
|
|
446
|
+
host.commands["checkout/answer"]({
|
|
447
|
+
inputId: asked.inputId,
|
|
448
|
+
answer: "x",
|
|
449
|
+
}),
|
|
450
|
+
),
|
|
451
|
+
).toEqual({ reason: "input-closed" });
|
|
452
|
+
expect(
|
|
453
|
+
await reason(host.commands["checkout/dismiss"]({ inputId: "nope" })),
|
|
454
|
+
).toEqual({ reason: "unknown-input" });
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
it("accepts an option, an option with its variant, or the user's own text", async () => {
|
|
458
|
+
const host = mount();
|
|
459
|
+
await host.commands["checkout/create"](seed);
|
|
460
|
+
await host.commands["checkout/begin-plan"]();
|
|
461
|
+
await host.commands["agent/hello"]({});
|
|
462
|
+
await host.commands["agent/ask"](presetInput("framework"));
|
|
463
|
+
const [framework] = host.state.inputs;
|
|
464
|
+
expect(framework?.kind).toBe("choice");
|
|
465
|
+
expect(framework?.preset).toBe("framework");
|
|
466
|
+
expect(framework?.options?.map((option) => option.id)).toEqual([
|
|
467
|
+
"ai-sdk",
|
|
468
|
+
"mastra",
|
|
469
|
+
"langgraph",
|
|
470
|
+
]);
|
|
471
|
+
expect(classifyChoiceAnswer(framework!, "langgraph")).toBe("invalid");
|
|
472
|
+
expect(classifyChoiceAnswer(framework!, "ai-sdk:x")).toBe("invalid");
|
|
473
|
+
expect(classifyChoiceAnswer(framework!, "langgraph:python")).toBe("option");
|
|
474
|
+
expect(classifyChoiceAnswer(framework!, "Hono with its own agent")).toBe(
|
|
475
|
+
"custom",
|
|
476
|
+
);
|
|
477
|
+
expect(classifyChoiceAnswer(framework!, "")).toBe("invalid");
|
|
478
|
+
|
|
479
|
+
expect(
|
|
480
|
+
await reason(
|
|
481
|
+
host.commands["checkout/answer"]({ inputId: "q1", answer: "ai-sdk" }),
|
|
482
|
+
),
|
|
483
|
+
).toEqual({ reason: "invalid-answer" });
|
|
484
|
+
await host.commands["checkout/answer"]({
|
|
485
|
+
inputId: "q1",
|
|
486
|
+
answer: "Hono with its own agent",
|
|
487
|
+
});
|
|
488
|
+
expect(host.state.inputs[0]?.answer).toBe("Hono with its own agent");
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
it("accepts any number of options when the question allows several", async () => {
|
|
492
|
+
const host = mount();
|
|
493
|
+
await host.commands["checkout/create"](seed);
|
|
494
|
+
await host.commands["checkout/begin-plan"]();
|
|
495
|
+
const { inputId } = await host.commands["agent/ask"]({
|
|
496
|
+
kind: "choice",
|
|
497
|
+
prompt: "Which integrations?",
|
|
498
|
+
options: [
|
|
499
|
+
{ id: "slack", label: "slack", icon: "message" },
|
|
500
|
+
{ id: "email", label: "email", icon: "mail" },
|
|
501
|
+
],
|
|
502
|
+
multiple: true,
|
|
503
|
+
});
|
|
504
|
+
const [input] = host.state.inputs;
|
|
505
|
+
expect(input?.multiple).toBe(true);
|
|
506
|
+
expect(input?.options?.[0]?.icon).toBe("message");
|
|
507
|
+
expect(classifyChoiceAnswer(input!, "slack")).toBe("invalid");
|
|
508
|
+
expect(classifyChoiceAnswer(input!, "[]")).toBe("invalid");
|
|
509
|
+
expect(classifyChoiceAnswer(input!, '["slack",""]')).toBe("invalid");
|
|
510
|
+
expect(classifyChoiceAnswer(input!, '["slack","email"]')).toBe("option");
|
|
511
|
+
expect(classifyChoiceAnswer(input!, '["email","Discord"]')).toBe("custom");
|
|
512
|
+
expect(
|
|
513
|
+
await reason(
|
|
514
|
+
host.commands["checkout/answer"]({ inputId, answer: "slack" }),
|
|
515
|
+
),
|
|
516
|
+
).toEqual({ reason: "invalid-answer" });
|
|
517
|
+
await host.commands["checkout/answer"]({
|
|
518
|
+
inputId,
|
|
519
|
+
answer: '["slack","email"]',
|
|
520
|
+
});
|
|
521
|
+
expect(host.state.inputs[0]?.answer).toBe('["slack","email"]');
|
|
522
|
+
|
|
523
|
+
expect(
|
|
524
|
+
await reason(
|
|
525
|
+
host.commands["agent/ask"]({
|
|
526
|
+
kind: "choice",
|
|
527
|
+
prompt: "Pick",
|
|
528
|
+
options: [
|
|
529
|
+
{ id: "a", label: "A", variants: [{ id: "x", label: "X" }] },
|
|
530
|
+
],
|
|
531
|
+
multiple: true,
|
|
532
|
+
}),
|
|
533
|
+
),
|
|
534
|
+
).toEqual({ reason: "invalid-input" });
|
|
535
|
+
expect(
|
|
536
|
+
await reason(
|
|
537
|
+
host.commands["agent/ask"]({ prompt: "Where?", multiple: true }),
|
|
538
|
+
),
|
|
539
|
+
).toEqual({ reason: "invalid-input" });
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
it("lists the projects the agent found ahead of a new project and its frameworks", async () => {
|
|
543
|
+
const host = await approvedHost();
|
|
544
|
+
const project = presetInput("project", {
|
|
545
|
+
found: [{ id: "apps/web", description: "Next.js" }, { id: "apps/admin" }],
|
|
546
|
+
});
|
|
547
|
+
expect(project.options?.map((option) => option.id)).toEqual([
|
|
548
|
+
"apps/web",
|
|
549
|
+
"apps/admin",
|
|
550
|
+
"new",
|
|
551
|
+
]);
|
|
552
|
+
const { inputId } = await host.commands["agent/ask"](project);
|
|
553
|
+
expect(
|
|
554
|
+
await reason(
|
|
555
|
+
host.commands["checkout/answer"]({ inputId, answer: "new" }),
|
|
556
|
+
),
|
|
557
|
+
).toEqual({ reason: "invalid-answer" });
|
|
558
|
+
await host.commands["checkout/answer"]({ inputId, answer: "new:vite" });
|
|
559
|
+
expect(host.state.inputs.at(-1)?.answer).toBe("new:vite");
|
|
560
|
+
expect(() => presetInput("project", { found: [{ id: "new" }] })).toThrow();
|
|
561
|
+
expect(() =>
|
|
562
|
+
presetInput("project", { found: [{ id: "C:\\app" }] }),
|
|
563
|
+
).toThrow();
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
it("restricts and defaults a preset, and rejects a choice without options", async () => {
|
|
567
|
+
const locked = presetInput("llm-provider", { only: ["anthropic"] });
|
|
568
|
+
expect(locked.options?.map((option) => option.id)).toEqual(["anthropic"]);
|
|
569
|
+
expect(locked.default).toBeUndefined();
|
|
570
|
+
expect(presetInput("llm-provider", { default: "groq" }).default).toBe(
|
|
571
|
+
"groq",
|
|
572
|
+
);
|
|
573
|
+
expect(() => presetInput("framework", { only: ["nope"] })).toThrow();
|
|
574
|
+
expect(() => presetInput("framework", { default: "nope" })).toThrow();
|
|
575
|
+
|
|
576
|
+
const host = mount();
|
|
577
|
+
await host.commands["checkout/create"](seed);
|
|
578
|
+
await host.commands["checkout/begin-plan"]();
|
|
579
|
+
expect(
|
|
580
|
+
await reason(
|
|
581
|
+
host.commands["agent/ask"]({
|
|
582
|
+
kind: "choice",
|
|
583
|
+
prompt: "Pick",
|
|
584
|
+
options: [],
|
|
585
|
+
}),
|
|
586
|
+
),
|
|
587
|
+
).toEqual({ reason: "invalid-input" });
|
|
588
|
+
expect(
|
|
589
|
+
await reason(
|
|
590
|
+
host.commands["agent/ask"]({
|
|
591
|
+
kind: "choice",
|
|
592
|
+
prompt: "Pick",
|
|
593
|
+
options: [{ id: "a", label: "A" }],
|
|
594
|
+
default: "b",
|
|
595
|
+
}),
|
|
596
|
+
),
|
|
597
|
+
).toEqual({ reason: "invalid-input" });
|
|
598
|
+
expect(
|
|
599
|
+
await reason(
|
|
600
|
+
host.commands["agent/ask"]({ prompt: "Pick", stepId: "nope" }),
|
|
601
|
+
),
|
|
602
|
+
).toEqual({ reason: "unknown-step" });
|
|
603
|
+
});
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
describe("model inputs", () => {
|
|
607
|
+
const answer = { provider: "openai", model: "gpt-test" };
|
|
608
|
+
|
|
609
|
+
it("parses a model answer and rejects malformed ones", () => {
|
|
610
|
+
expect(parseModelAnswer(JSON.stringify(answer))).toEqual(answer);
|
|
611
|
+
expect(
|
|
612
|
+
parseModelAnswer(JSON.stringify({ ...answer, reasoningEffort: "high" })),
|
|
613
|
+
).toEqual({ ...answer, reasoningEffort: "high" });
|
|
614
|
+
expect(parseModelAnswer("openai")).toBeUndefined();
|
|
615
|
+
expect(parseModelAnswer(JSON.stringify({ ...answer, model: "" }))).toBe(
|
|
616
|
+
undefined,
|
|
617
|
+
);
|
|
618
|
+
expect(
|
|
619
|
+
parseModelAnswer(JSON.stringify({ ...answer, reasoningEffort: "max" })),
|
|
620
|
+
).toBeUndefined();
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
it("asks with the llm-provider preset and only accepts a JSON answer for a listed provider", async () => {
|
|
624
|
+
const host = mount();
|
|
625
|
+
await host.commands["checkout/create"](seed);
|
|
626
|
+
await host.commands["checkout/begin-plan"]();
|
|
627
|
+
const asked = await host.commands["agent/ask"](presetInput("llm-provider"));
|
|
628
|
+
const input = host.state.inputs.find((entry) => entry.id === asked.inputId);
|
|
629
|
+
expect(input?.kind).toBe("model");
|
|
630
|
+
expect(input?.default).toBe("openai");
|
|
631
|
+
expect(input?.options?.map((option) => option.id)).toContain("openrouter");
|
|
632
|
+
|
|
633
|
+
expect(
|
|
634
|
+
await reason(
|
|
635
|
+
host.commands["checkout/answer"]({
|
|
636
|
+
inputId: asked.inputId,
|
|
637
|
+
answer: "openai",
|
|
638
|
+
}),
|
|
639
|
+
),
|
|
640
|
+
).toEqual({ reason: "invalid-answer" });
|
|
641
|
+
expect(
|
|
642
|
+
await reason(
|
|
643
|
+
host.commands["checkout/answer"]({
|
|
644
|
+
inputId: asked.inputId,
|
|
645
|
+
answer: JSON.stringify({ ...answer, provider: "nope" }),
|
|
646
|
+
}),
|
|
647
|
+
),
|
|
648
|
+
).toEqual({ reason: "invalid-answer" });
|
|
649
|
+
await host.commands["checkout/answer"]({
|
|
650
|
+
inputId: asked.inputId,
|
|
651
|
+
answer: JSON.stringify(answer),
|
|
652
|
+
});
|
|
653
|
+
expect(parseModelAnswer(input?.answer ?? "")).toEqual(answer);
|
|
654
|
+
|
|
655
|
+
expect(
|
|
656
|
+
await reason(
|
|
657
|
+
host.commands["agent/ask"]({ kind: "model", prompt: "Model?" }),
|
|
658
|
+
),
|
|
659
|
+
).toEqual({ reason: "invalid-input" });
|
|
660
|
+
});
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
describe("agent identity", () => {
|
|
664
|
+
it("records the intro and the agent kind without marking the agent present", async () => {
|
|
665
|
+
const host = mount();
|
|
666
|
+
await host.commands["checkout/create"](seed);
|
|
667
|
+
await host.commands["agent/intro"]({ kind: "claude" });
|
|
668
|
+
expect(host.state.status).toBe("waiting");
|
|
669
|
+
expect(host.state.agent.lastSeenAt).toBeNull();
|
|
670
|
+
expect(host.state.agent.kind).toBe("claude");
|
|
671
|
+
const introducedAt = host.state.agent.introducedAt;
|
|
672
|
+
expect(introducedAt).not.toBeNull();
|
|
673
|
+
await host.commands["agent/intro"]({});
|
|
674
|
+
expect(host.state.agent.introducedAt).toBe(introducedAt);
|
|
675
|
+
await host.commands["agent/hello"]({ cwd: "/app", kind: "codex" });
|
|
676
|
+
expect(host.state.agent.kind).toBe("codex");
|
|
677
|
+
expect(host.state.status).toBe("waiting");
|
|
678
|
+
});
|
|
679
|
+
|
|
680
|
+
it("drops presence the moment the agent says bye", async () => {
|
|
681
|
+
const host = mount();
|
|
682
|
+
await host.commands["checkout/create"](seed);
|
|
683
|
+
await host.commands["agent/hello"]({});
|
|
684
|
+
expect(isAgentPresent(host.state)).toBe(true);
|
|
685
|
+
await host.commands["agent/bye"]();
|
|
686
|
+
expect(host.state.agent.lastSeenAt).not.toBeNull();
|
|
687
|
+
expect(isAgentPresent(host.state)).toBe(false);
|
|
688
|
+
await host.commands["agent/heartbeat"]();
|
|
689
|
+
expect(isAgentPresent(host.state)).toBe(true);
|
|
690
|
+
});
|
|
691
|
+
});
|
|
692
|
+
|
|
693
|
+
describe("setup chat", () => {
|
|
694
|
+
it("preserves order instructions and records the stage and substep for both speakers", async () => {
|
|
695
|
+
const host = mount();
|
|
696
|
+
await host.commands["checkout/create"]({
|
|
697
|
+
...seed,
|
|
698
|
+
instructions: " Use our offline gateway. ",
|
|
699
|
+
});
|
|
700
|
+
expect(host.state.instructions).toBe("Use our offline gateway.");
|
|
701
|
+
await host.commands["checkout/message"]({ text: "Keep our theme" });
|
|
702
|
+
expect(host.state.log[0]?.phase).toBe("waiting");
|
|
703
|
+
await host.commands["checkout/begin-plan"]();
|
|
704
|
+
await host.commands["agent/log"]({ text: "Reading your project" });
|
|
705
|
+
expect(host.state.log[1]?.phase).toBe("planning");
|
|
706
|
+
await host.commands["agent/plan"]({ markdown: "Add chat" });
|
|
707
|
+
await host.commands["checkout/plan"]({ decision: "approve" });
|
|
708
|
+
const { stepId } = await host.commands["agent/add-step"]({
|
|
709
|
+
title: "Add route",
|
|
710
|
+
active: true,
|
|
711
|
+
});
|
|
712
|
+
await host.commands["agent/log"]({ text: "Adding the route" });
|
|
713
|
+
await host.commands["checkout/message"]({ text: "Use /chat" });
|
|
714
|
+
expect(host.state.log.slice(-2)).toEqual(
|
|
715
|
+
expect.arrayContaining([
|
|
716
|
+
expect.objectContaining({ role: "agent", phase: "installing", stepId }),
|
|
717
|
+
expect.objectContaining({ role: "user", phase: "installing", stepId }),
|
|
718
|
+
]),
|
|
719
|
+
);
|
|
720
|
+
const { inputId } = await host.commands["agent/ask"]({
|
|
721
|
+
prompt: "Which layout?",
|
|
722
|
+
});
|
|
723
|
+
expect(
|
|
724
|
+
host.state.inputs.find((input) => input.id === inputId),
|
|
725
|
+
).toMatchObject({ phase: "installing", stepId });
|
|
726
|
+
expect(mount(host.snapshot()).state.instructions).toBe(
|
|
727
|
+
"Use our offline gateway.",
|
|
728
|
+
);
|
|
729
|
+
});
|
|
730
|
+
|
|
731
|
+
it("stores user steering before connection and acknowledges it without resolving a question", async () => {
|
|
732
|
+
const host = mount();
|
|
733
|
+
await host.commands["checkout/create"](seed);
|
|
734
|
+
await host.commands["checkout/message"]({
|
|
735
|
+
text: " Use the existing endpoint. ",
|
|
736
|
+
});
|
|
737
|
+
expect(host.state.status).toBe("waiting");
|
|
738
|
+
expect(host.state.log[0]).toMatchObject({
|
|
739
|
+
id: "l1",
|
|
740
|
+
role: "user",
|
|
741
|
+
text: "Use the existing endpoint.",
|
|
742
|
+
});
|
|
743
|
+
await host.commands["checkout/begin-plan"]();
|
|
744
|
+
const { inputId } = await host.commands["agent/ask"]({
|
|
745
|
+
prompt: "Which project?",
|
|
746
|
+
});
|
|
747
|
+
await host.commands["agent/ack"]({ messageId: "l1" });
|
|
748
|
+
expect(host.state.log[0]?.acknowledgedAt).toEqual(expect.any(Number));
|
|
749
|
+
expect(
|
|
750
|
+
host.state.inputs.find((input) => input.id === inputId)?.status,
|
|
751
|
+
).toBe("open");
|
|
752
|
+
expect(mount(host.snapshot()).state.log).toEqual(host.state.log);
|
|
753
|
+
await host.commands["agent/log"]({ text: "I will reuse it." });
|
|
754
|
+
expect(host.state.log[1]?.role).toBe("agent");
|
|
755
|
+
});
|
|
756
|
+
|
|
757
|
+
it("rejects empty messages, unknown acknowledgments, and messages after cancellation", async () => {
|
|
758
|
+
const host = mount();
|
|
759
|
+
await host.commands["checkout/create"](seed);
|
|
760
|
+
expect(
|
|
761
|
+
await reason(host.commands["checkout/message"]({ text: " " })),
|
|
762
|
+
).toEqual({ reason: "empty-message" });
|
|
763
|
+
expect(
|
|
764
|
+
await reason(host.commands["agent/ack"]({ messageId: "missing" })),
|
|
765
|
+
).toEqual({ reason: "unknown-message" });
|
|
766
|
+
await host.commands["checkout/cancel"]();
|
|
767
|
+
expect(
|
|
768
|
+
await reason(host.commands["checkout/message"]({ text: "hello" })),
|
|
769
|
+
).toEqual({ reason: "closed" });
|
|
770
|
+
});
|
|
771
|
+
});
|
|
772
|
+
|
|
773
|
+
describe("entry-point questions", () => {
|
|
774
|
+
const options = [
|
|
775
|
+
{
|
|
776
|
+
id: "ticket-sidebar",
|
|
777
|
+
label: "Ticket sidebar",
|
|
778
|
+
description: "Keep the support ticket visible while drafting a reply.",
|
|
779
|
+
entryPoint: {
|
|
780
|
+
formFactor: "sidebar" as const,
|
|
781
|
+
placement: "Right side of the ticket detail page",
|
|
782
|
+
trigger: "Draft reply button in the ticket toolbar",
|
|
783
|
+
recommended: true,
|
|
784
|
+
},
|
|
785
|
+
},
|
|
786
|
+
{
|
|
787
|
+
id: "inbox-sidebar",
|
|
788
|
+
label: "Inbox sidebar",
|
|
789
|
+
description: "Draft replies from the selected ticket in the inbox.",
|
|
790
|
+
entryPoint: {
|
|
791
|
+
formFactor: "sidebar" as const,
|
|
792
|
+
placement: "Right side of the inbox",
|
|
793
|
+
trigger: "Assist button in the inbox header",
|
|
794
|
+
},
|
|
795
|
+
},
|
|
796
|
+
];
|
|
797
|
+
|
|
798
|
+
it("persists agent-generated placements and answers by stable id across restore", async () => {
|
|
799
|
+
const host = mount();
|
|
800
|
+
await host.commands["checkout/create"](seed);
|
|
801
|
+
await host.commands["checkout/begin-plan"]();
|
|
802
|
+
const { inputId } = await host.commands["agent/ask"]({
|
|
803
|
+
kind: "entry-point",
|
|
804
|
+
prompt: "Where should support agents draft replies?",
|
|
805
|
+
options,
|
|
806
|
+
default: "ticket-sidebar",
|
|
807
|
+
});
|
|
808
|
+
expect(host.state.inputs[0]?.options).toEqual(options);
|
|
809
|
+
for (const answer of [
|
|
810
|
+
"sidebar",
|
|
811
|
+
"ticket-sidebar:variant",
|
|
812
|
+
'["ticket-sidebar"]',
|
|
813
|
+
"",
|
|
814
|
+
]) {
|
|
815
|
+
expect(
|
|
816
|
+
await reason(host.commands["checkout/answer"]({ inputId, answer })),
|
|
817
|
+
).toEqual({ reason: "invalid-answer" });
|
|
818
|
+
}
|
|
819
|
+
await host.commands["checkout/answer"]({
|
|
820
|
+
inputId,
|
|
821
|
+
answer: "inbox-sidebar",
|
|
822
|
+
note: "Keep the ticket list visible",
|
|
823
|
+
});
|
|
824
|
+
const restored = mount(host.snapshot());
|
|
825
|
+
expect(restored.state.inputs[0]).toMatchObject({
|
|
826
|
+
kind: "entry-point",
|
|
827
|
+
options,
|
|
828
|
+
status: "answered",
|
|
829
|
+
answer: "inbox-sidebar",
|
|
830
|
+
note: "Keep the ticket list visible",
|
|
831
|
+
});
|
|
832
|
+
});
|
|
833
|
+
|
|
834
|
+
it("rejects invalid structured inputs before they enter persisted state", async () => {
|
|
835
|
+
const host = mount();
|
|
836
|
+
await host.commands["checkout/create"](seed);
|
|
837
|
+
await host.commands["checkout/begin-plan"]();
|
|
838
|
+
for (const invalid of [
|
|
839
|
+
{ options: [] },
|
|
840
|
+
{ options: [options[0]!, options[0]!] },
|
|
841
|
+
{ options, multiple: true as const },
|
|
842
|
+
{ options, default: "unknown" },
|
|
843
|
+
{ options: [{ ...options[0]!, description: "" }] },
|
|
844
|
+
{
|
|
845
|
+
options: [
|
|
846
|
+
{
|
|
847
|
+
...options[0]!,
|
|
848
|
+
entryPoint: { ...options[0]!.entryPoint, trigger: "" },
|
|
849
|
+
},
|
|
850
|
+
],
|
|
851
|
+
},
|
|
852
|
+
]) {
|
|
853
|
+
expect(
|
|
854
|
+
await reason(
|
|
855
|
+
host.commands["agent/ask"]({
|
|
856
|
+
kind: "entry-point",
|
|
857
|
+
prompt: "Where?",
|
|
858
|
+
...invalid,
|
|
859
|
+
}),
|
|
860
|
+
),
|
|
861
|
+
).toEqual({ reason: "invalid-input" });
|
|
862
|
+
}
|
|
863
|
+
expect(host.state.inputs).toEqual([]);
|
|
864
|
+
});
|
|
865
|
+
});
|
|
866
|
+
|
|
867
|
+
describe("product discovery", () => {
|
|
868
|
+
it("rejects an empty related-product identifier", async () => {
|
|
869
|
+
const host = await approvedHost();
|
|
870
|
+
expect(
|
|
871
|
+
await reason(
|
|
872
|
+
host.commands["agent/add-step"]({
|
|
873
|
+
title: "Install integration",
|
|
874
|
+
product: " ",
|
|
875
|
+
}),
|
|
876
|
+
),
|
|
877
|
+
).toEqual({ reason: "invalid-input" });
|
|
878
|
+
expect(host.state.steps).toEqual([]);
|
|
879
|
+
});
|
|
880
|
+
|
|
881
|
+
it("tracks relevant products outside the starting list without changing that list", async () => {
|
|
882
|
+
const host = await approvedHost();
|
|
883
|
+
const discovered = await host.commands["agent/add-step"]({
|
|
884
|
+
title: "Install discovered integration",
|
|
885
|
+
product: "discovered-integration",
|
|
886
|
+
});
|
|
887
|
+
expect(
|
|
888
|
+
host.state.steps.find((step) => step.id === discovered.stepId),
|
|
889
|
+
).toMatchObject({
|
|
890
|
+
product: "discovered-integration",
|
|
891
|
+
});
|
|
892
|
+
await host.commands["agent/step"]({
|
|
893
|
+
stepId: discovered.stepId,
|
|
894
|
+
status: "done",
|
|
895
|
+
});
|
|
896
|
+
const restored = mount(host.snapshot());
|
|
897
|
+
expect(restored.state.steps[0]).toMatchObject({
|
|
898
|
+
product: "discovered-integration",
|
|
899
|
+
status: "done",
|
|
900
|
+
});
|
|
901
|
+
expect(restored.state.products).toEqual(seed.products);
|
|
902
|
+
});
|
|
903
|
+
});
|
|
904
|
+
|
|
905
|
+
describe("closing", () => {
|
|
906
|
+
it("keeps a finished setup done when a late cancel arrives", async () => {
|
|
907
|
+
const host = await approvedHost();
|
|
908
|
+
await host.commands["agent/done"]();
|
|
909
|
+
await host.commands["checkout/finish"]();
|
|
910
|
+
await host.commands["checkout/cancel"]();
|
|
911
|
+
expect(host.state.status).toBe("done");
|
|
912
|
+
});
|
|
913
|
+
|
|
914
|
+
it("ignores a cancel meant for another checkout", async () => {
|
|
915
|
+
const host = mount();
|
|
916
|
+
await host.commands["checkout/create"](seed);
|
|
917
|
+
await host.commands["checkout/cancel"]({ id: "c0" });
|
|
918
|
+
expect(host.state.status).toBe("waiting");
|
|
919
|
+
});
|
|
920
|
+
|
|
921
|
+
it("cancels the checkout a cancel names", async () => {
|
|
922
|
+
const host = mount();
|
|
923
|
+
await host.commands["checkout/create"](seed);
|
|
924
|
+
await host.commands["checkout/cancel"]({ id: seed.id });
|
|
925
|
+
expect(host.state.status).toBe("cancelled");
|
|
926
|
+
});
|
|
927
|
+
|
|
928
|
+
it("cancels the current checkout when a cancel names none", async () => {
|
|
929
|
+
const host = mount();
|
|
930
|
+
await host.commands["checkout/create"](seed);
|
|
931
|
+
await host.commands["checkout/cancel"]({});
|
|
932
|
+
expect(host.state.status).toBe("cancelled");
|
|
933
|
+
});
|
|
934
|
+
|
|
935
|
+
it("ignores a cancel before the setup exists", async () => {
|
|
936
|
+
const host = mount();
|
|
937
|
+
await host.commands["checkout/cancel"]();
|
|
938
|
+
expect(host.state.status).toBe(initialCheckoutState().status);
|
|
939
|
+
});
|
|
940
|
+
|
|
941
|
+
it("stops counting a follow-up once the agent acknowledges it", async () => {
|
|
942
|
+
vi.useFakeTimers({ toFake: ["Date"] });
|
|
943
|
+
onTestFinished(() => {
|
|
944
|
+
vi.useRealTimers();
|
|
945
|
+
});
|
|
946
|
+
const host = await approvedHost();
|
|
947
|
+
await host.commands["agent/done"]();
|
|
948
|
+
vi.advanceTimersByTime(1);
|
|
949
|
+
await host.commands["checkout/message"]({ text: "One more thing" });
|
|
950
|
+
expect(followedUpSinceProposal(host.state)).toBe(true);
|
|
951
|
+
const message = host.state.log.at(-1)!;
|
|
952
|
+
await host.commands["agent/ack"]({ messageId: message.id });
|
|
953
|
+
expect(followedUpSinceProposal(host.state)).toBe(false);
|
|
954
|
+
});
|
|
955
|
+
});
|
|
956
|
+
|
|
957
|
+
describe("choice option ids", () => {
|
|
958
|
+
const ask = async (options: Checkout.ChoiceOption[], multiple = false) => {
|
|
959
|
+
const host = mount();
|
|
960
|
+
await host.commands["checkout/create"](seed);
|
|
961
|
+
await host.commands["checkout/begin-plan"]();
|
|
962
|
+
return host.commands["agent/ask"]({
|
|
963
|
+
kind: "choice",
|
|
964
|
+
prompt: "Pick",
|
|
965
|
+
options,
|
|
966
|
+
...(multiple && { multiple }),
|
|
967
|
+
});
|
|
968
|
+
};
|
|
969
|
+
|
|
970
|
+
it("accepts several answers when every option has an empty variant list", async () => {
|
|
971
|
+
await expect(
|
|
972
|
+
ask([{ id: "a", label: "A", variants: [] }], true),
|
|
973
|
+
).resolves.toEqual({ inputId: "q1" });
|
|
974
|
+
});
|
|
975
|
+
|
|
976
|
+
it.each([
|
|
977
|
+
[[{ id: "a:b", label: "A" }]],
|
|
978
|
+
[[{ id: "a", label: "A", variants: [{ id: "x:y", label: "X" }] }]],
|
|
979
|
+
[
|
|
980
|
+
[
|
|
981
|
+
{ id: "a", label: "A" },
|
|
982
|
+
{ id: "a", label: "Again" },
|
|
983
|
+
],
|
|
984
|
+
],
|
|
985
|
+
[
|
|
986
|
+
[
|
|
987
|
+
{
|
|
988
|
+
id: "a",
|
|
989
|
+
label: "A",
|
|
990
|
+
variants: [
|
|
991
|
+
{ id: "x", label: "X" },
|
|
992
|
+
{ id: "x", label: "Again" },
|
|
993
|
+
],
|
|
994
|
+
},
|
|
995
|
+
],
|
|
996
|
+
],
|
|
997
|
+
])("refuses option ids an answer cannot name: %j", async (options) => {
|
|
998
|
+
expect(await reason(ask(options))).toEqual({ reason: "invalid-input" });
|
|
999
|
+
});
|
|
1000
|
+
|
|
1001
|
+
it("treats an answer with more than one separator as the user's own text", async () => {
|
|
1002
|
+
const host = await approvedHost();
|
|
1003
|
+
const { inputId } = await host.commands["agent/ask"](
|
|
1004
|
+
presetInput("framework"),
|
|
1005
|
+
);
|
|
1006
|
+
expect(parseChoiceAnswer("langgraph:python:other")).toEqual({
|
|
1007
|
+
option: "langgraph:python:other",
|
|
1008
|
+
});
|
|
1009
|
+
await host.commands["checkout/answer"]({
|
|
1010
|
+
inputId,
|
|
1011
|
+
answer: "langgraph:python:other",
|
|
1012
|
+
});
|
|
1013
|
+
expect(
|
|
1014
|
+
classifyChoiceAnswer(host.state.inputs[0]!, "langgraph:python:other"),
|
|
1015
|
+
).toBe("custom");
|
|
1016
|
+
});
|
|
1017
|
+
|
|
1018
|
+
it("locks a preset to --only even when the agent found its own options", () => {
|
|
1019
|
+
const locked = presetInput("project", {
|
|
1020
|
+
only: ["new"],
|
|
1021
|
+
found: [{ id: "apps/web" }],
|
|
1022
|
+
});
|
|
1023
|
+
expect(locked.options?.map((option) => option.id)).toEqual(["new"]);
|
|
1024
|
+
});
|
|
1025
|
+
|
|
1026
|
+
it("knows only its own presets", () => {
|
|
1027
|
+
expect(isPresetId("framework")).toBe(true);
|
|
1028
|
+
expect(isPresetId("toString")).toBe(false);
|
|
1029
|
+
});
|
|
1030
|
+
});
|