setup-agent 0.0.4 → 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/README.md +6 -4
- package/dist/cli.d.ts +1 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +44 -11
- package/dist/cli.js.map +1 -1
- package/dist/host.d.ts.map +1 -1
- package/dist/host.js +8 -4
- package/dist/host.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/protocol.d.ts +21 -2
- package/dist/protocol.d.ts.map +1 -1
- package/dist/protocol.js +30 -1
- package/dist/protocol.js.map +1 -1
- package/package.json +1 -1
- package/src/cli.test.ts +151 -0
- package/src/cli.ts +58 -9
- package/src/host.test.ts +153 -6
- package/src/host.ts +27 -7
- package/src/index.ts +3 -0
- package/src/protocol.test.ts +128 -0
- package/src/protocol.ts +93 -2
package/src/cli.ts
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
isClosed,
|
|
15
15
|
isOptionIcon,
|
|
16
16
|
isValidModelAnswer,
|
|
17
|
+
parseEntryPointOptions,
|
|
17
18
|
parseMultipleAnswer,
|
|
18
19
|
stepProgress,
|
|
19
20
|
} from "./protocol";
|
|
@@ -28,17 +29,16 @@ Commands (the url always comes first):
|
|
|
28
29
|
ack <message-id> Acknowledge a user message.
|
|
29
30
|
ask <prompt> [--placeholder <text>] [--optional] [--step <step-id>] [--wait]
|
|
30
31
|
Ask the user for a line of text.
|
|
31
|
-
ask [prompt] --preset <framework|llm-provider
|
|
32
|
+
ask [prompt] --preset <framework|llm-provider> [--only <id,id>] [--choices <id=description,...>] [--default <id>] [--optional] [--step <step-id>] [--wait]
|
|
32
33
|
Ask a standard question. --only restricts the options and leaves out --choices (one id locks it in); --default preselects one.
|
|
33
34
|
"framework" answers "<framework>:<language>"; "llm-provider" answers JSON with provider, model and reasoningEffort; the key itself arrives through "env".
|
|
34
|
-
"project" lists the React projects you found as --choices "<path>=<what it is>,..." ahead of "New project"; it answers a path, or "new:<next|vite|react-router|tanstack-start|expo>".
|
|
35
35
|
ask <prompt> --choices <id,id,...> --icons <id=icon,...> [--single] [--default <id>] [--optional] [--step <step-id>] [--wait]
|
|
36
36
|
Ask the user to pick from your own options; they may also type their own answer.
|
|
37
37
|
The user checks any number of options and the answer is a JSON array of option ids (their own text is one entry).
|
|
38
38
|
--single is for options that exclude each other (one framework, one model, yes or no); the answer is then one id.
|
|
39
39
|
--icons is required and names an icon for every option, from: ${OPTION_ICONS.join(", ")}.
|
|
40
|
-
ask <prompt> --
|
|
41
|
-
|
|
40
|
+
ask <prompt> --entry-points '<JSON array>' [--default <id>] [--optional] [--step <step-id>] [--wait]
|
|
41
|
+
Ask where users should access the assistant. Each of 1–3 options has id, label, description and entryPoint {formFactor: modal|sidebar|full-page, placement, trigger, recommended?}. Answers one option id.
|
|
42
42
|
wait start Block until the user clicks Begin plan in their browser; after a finished or cancelled setup, until the next one begins.
|
|
43
43
|
wait <input-id> Block until the user answers or dismisses an input. A multi-select answer prints as an array.
|
|
44
44
|
plan (<markdown> | --file <path>) [--wait]
|
|
@@ -54,7 +54,7 @@ Commands (the url always comes first):
|
|
|
54
54
|
Mark every remaining step done and propose closing; the user closes it.
|
|
55
55
|
`;
|
|
56
56
|
|
|
57
|
-
export const
|
|
57
|
+
export const agentInstructions = (
|
|
58
58
|
url: string,
|
|
59
59
|
) => `You are the coding agent for a setup the user opened in their browser. The browser shows this setup live; every command below updates it. The user watches the browser, not this terminal: once setup begins, send questions through "ask" so the user can answer in their browser.
|
|
60
60
|
|
|
@@ -73,13 +73,14 @@ Keep the stream running while you wait. Do not inspect the project, fetch guides
|
|
|
73
73
|
The remaining phases are plan, then install.
|
|
74
74
|
|
|
75
75
|
1. Plan. Investigate only; change nothing yet. If the current directory is not an app to install into (a monorepo root, an empty folder, a library), ask where to install first:
|
|
76
|
-
npx setup-agent ${url} ask "Which project should I install into?" --placeholder "An absolute path
|
|
76
|
+
npx setup-agent ${url} ask "Which project should I install into?" --placeholder "An absolute path to the app" --wait
|
|
77
77
|
Read the project: its app framework and package manager, the agent framework and model provider it already uses, where components live, any existing chat route. Fetch each product's "guide" URL from the setup below; it is the install reference. Ask for what the project does not decide, using the standard questions so the browser can render them well:
|
|
78
78
|
npx setup-agent ${url} ask --preset framework
|
|
79
79
|
npx setup-agent ${url} ask --preset llm-provider
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
80
|
+
The setup's products are starting names and guide references. Explore each product's setup instructions, then discover and plan any other relevant products or dependencies the app needs. Read /catalog.md on the guide URL’s origin to find related products and fetch their setup guides. The starting list does not restrict the implementation or require adding another product to the list. Include relevant additions in the plan for approval.
|
|
81
|
+
When the setup puts an assistant in the app, ask how users should access it after discovering the actual app and before proposing the plan. Skip this question when no product's guide installs an assistant (a standalone library or service setup, for example). Use an entry-point question with only 1–3 sensible alternatives. Describe the actual app's pages, workflow and component placement, not generic placeholder copy. Recommend one clear fit when there is one; two options may use the same form factor in different places. Each option needs a stable id, label, contextual description and entryPoint {"formFactor":"modal"|"sidebar"|"full-page","placement":"where it appears","trigger":"how users open it or navigate to it","recommended":true?}. Write the JSON array for this app and send it with:
|
|
82
|
+
npx setup-agent ${url} ask "Where should users access the assistant?" --entry-points '<JSON array>' --wait
|
|
83
|
+
Mark a clear fit with "recommended":true rather than --default, so the user makes the pick. When you ask it, do not submit the plan before the user picks an entry point. Respect the selected placement and trigger in the plan and implementation. Do not ask for a new-versus-existing app setting; discover the app from its files and ask only for an install path when needed.
|
|
83
84
|
Skip a preset the code already answers (a chat route on the AI SDK, a provider key in .env.local) and log why. Ask everything at once, keep investigating, and block with "wait <input-id>" only when nothing else can proceed. A framework answer is "<framework>:<language>", for example "ai-sdk:typescript". A model answer is JSON {"provider","model","reasoningEffort"?}. An answer may carry a "note" from the user; follow it. A choice answer that matches none of the options is the user's own text.
|
|
84
85
|
Then write the plan as markdown and submit it:
|
|
85
86
|
npx setup-agent ${url} plan --file <path> --wait
|
|
@@ -99,6 +100,8 @@ Before that, start the project's dev server in the background when the project h
|
|
|
99
100
|
which returns once that setup begins, then run "status" for its products, guides and instructions and go through plan and install again. A "setup.created" event while you are still working means the user replaced this setup: drop what you were doing and start over the same way.
|
|
100
101
|
`;
|
|
101
102
|
|
|
103
|
+
export const INSTRUCTIONS = agentInstructions;
|
|
104
|
+
|
|
102
105
|
type Parsed = { positional: string[]; flags: Map<string, string | true> };
|
|
103
106
|
|
|
104
107
|
const parseArgs = (argv: readonly string[]): Parsed => {
|
|
@@ -506,6 +509,52 @@ export const askSeed = (
|
|
|
506
509
|
const stepId = flagText(flags, "step");
|
|
507
510
|
const optional = flags.has("optional");
|
|
508
511
|
const fallback = flagText(flags, "default");
|
|
512
|
+
if (flags.has("entry-points")) {
|
|
513
|
+
if (
|
|
514
|
+
[
|
|
515
|
+
"preset",
|
|
516
|
+
"product",
|
|
517
|
+
"choices",
|
|
518
|
+
"icons",
|
|
519
|
+
"multiple",
|
|
520
|
+
"single",
|
|
521
|
+
"only",
|
|
522
|
+
"placeholder",
|
|
523
|
+
].some((flag) => flags.has(flag))
|
|
524
|
+
)
|
|
525
|
+
return fail(
|
|
526
|
+
"--entry-points cannot be combined with other input kinds, --multiple, --single or --only",
|
|
527
|
+
);
|
|
528
|
+
const json = flagText(flags, "entry-points");
|
|
529
|
+
let value: unknown;
|
|
530
|
+
try {
|
|
531
|
+
value = JSON.parse(json ?? "");
|
|
532
|
+
} catch {
|
|
533
|
+
return fail("--entry-points needs a JSON array of options");
|
|
534
|
+
}
|
|
535
|
+
const options = parseEntryPointOptions(value);
|
|
536
|
+
if (!options)
|
|
537
|
+
return fail(
|
|
538
|
+
"--entry-points needs 1–3 unique options with id, label, description and entryPoint {formFactor, placement, trigger, recommended?}; at most one is recommended",
|
|
539
|
+
);
|
|
540
|
+
if (
|
|
541
|
+
fallback !== undefined &&
|
|
542
|
+
!options.some((option) => option.id === fallback)
|
|
543
|
+
)
|
|
544
|
+
return fail(`no option "${fallback}" to default to`);
|
|
545
|
+
const prompt =
|
|
546
|
+
rest[0] ?? fail("usage: ask <prompt> --entry-points '<JSON array>'");
|
|
547
|
+
if (prompt.trim() === "")
|
|
548
|
+
return fail("an entry-point prompt cannot be empty");
|
|
549
|
+
return {
|
|
550
|
+
kind: "entry-point",
|
|
551
|
+
prompt,
|
|
552
|
+
options,
|
|
553
|
+
...(fallback !== undefined && { default: fallback }),
|
|
554
|
+
optional,
|
|
555
|
+
...(stepId !== undefined && { stepId }),
|
|
556
|
+
};
|
|
557
|
+
}
|
|
509
558
|
const preset = flagText(flags, "preset");
|
|
510
559
|
if (preset !== undefined) {
|
|
511
560
|
if (!isPresetId(preset)) {
|
package/src/host.test.ts
CHANGED
|
@@ -310,12 +310,6 @@ describe("steps", () => {
|
|
|
310
310
|
host.commands["agent/step"]({ stepId: "nope", status: "done" }),
|
|
311
311
|
),
|
|
312
312
|
).toEqual({ reason: "unknown-step" });
|
|
313
|
-
expect(
|
|
314
|
-
await reason(
|
|
315
|
-
host.commands["agent/add-step"]({ title: "x", product: "nope" }),
|
|
316
|
-
),
|
|
317
|
-
).toEqual({ reason: "unknown-product" });
|
|
318
|
-
|
|
319
313
|
await host.commands["agent/ask"]({ prompt: "Port?", stepId: "s2" });
|
|
320
314
|
expect(await reason(host.commands["checkout/finish"]())).toEqual({
|
|
321
315
|
reason: "finish-not-proposed",
|
|
@@ -776,6 +770,138 @@ describe("setup chat", () => {
|
|
|
776
770
|
});
|
|
777
771
|
});
|
|
778
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
|
+
|
|
779
905
|
describe("closing", () => {
|
|
780
906
|
it("keeps a finished setup done when a late cancel arrives", async () => {
|
|
781
907
|
const host = await approvedHost();
|
|
@@ -785,6 +911,27 @@ describe("closing", () => {
|
|
|
785
911
|
expect(host.state.status).toBe("done");
|
|
786
912
|
});
|
|
787
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
|
+
|
|
788
935
|
it("ignores a cancel before the setup exists", async () => {
|
|
789
936
|
const host = mount();
|
|
790
937
|
await host.commands["checkout/cancel"]();
|
package/src/host.ts
CHANGED
|
@@ -12,6 +12,9 @@ import {
|
|
|
12
12
|
isAgentPresent,
|
|
13
13
|
isClosed,
|
|
14
14
|
isValidModelAnswer,
|
|
15
|
+
isValidEntryPointAnswer,
|
|
16
|
+
isValidEntryPointInput,
|
|
17
|
+
parseEntryPointOptions,
|
|
15
18
|
parsePreviewUrl,
|
|
16
19
|
} from "./protocol";
|
|
17
20
|
import type { Checkout } from "./protocol";
|
|
@@ -83,6 +86,16 @@ const useCheckoutHost = (restored: unknown) => {
|
|
|
83
86
|
|
|
84
87
|
const ask = (seed: Checkout.InputSeed & { stepId?: string }) => {
|
|
85
88
|
const kind = seed.kind ?? "text";
|
|
89
|
+
const options =
|
|
90
|
+
kind === "entry-point"
|
|
91
|
+
? parseEntryPointOptions(seed.options)
|
|
92
|
+
: seed.options;
|
|
93
|
+
if (kind === "entry-point" && !isValidEntryPointInput(seed)) {
|
|
94
|
+
throw reject(
|
|
95
|
+
"invalid-input",
|
|
96
|
+
"an entry-point input needs a prompt and 1–3 distinct options with descriptions, form factors, placements and triggers; at most one is recommended",
|
|
97
|
+
);
|
|
98
|
+
}
|
|
86
99
|
if (kind === "product") {
|
|
87
100
|
if (!seed.product) {
|
|
88
101
|
throw reject("invalid-input", "a product input names a product");
|
|
@@ -148,7 +161,7 @@ const useCheckoutHost = (restored: unknown) => {
|
|
|
148
161
|
...(seed.preset !== undefined && { preset: seed.preset }),
|
|
149
162
|
prompt: seed.prompt,
|
|
150
163
|
...(seed.placeholder !== undefined && { placeholder: seed.placeholder }),
|
|
151
|
-
...(
|
|
164
|
+
...(options !== undefined && { options }),
|
|
152
165
|
...(seed.multiple && { multiple: true }),
|
|
153
166
|
...(kind === "product" && { product: seed.product }),
|
|
154
167
|
...(seed.default !== undefined && { default: seed.default }),
|
|
@@ -237,6 +250,12 @@ const useCheckoutHost = (restored: unknown) => {
|
|
|
237
250
|
: `"${answer}" names an option without a valid variant`,
|
|
238
251
|
);
|
|
239
252
|
}
|
|
253
|
+
if (
|
|
254
|
+
input.kind === "entry-point" &&
|
|
255
|
+
!isValidEntryPointAnswer(input, answer)
|
|
256
|
+
) {
|
|
257
|
+
throw reject("invalid-answer", "an entry-point answer is an option id");
|
|
258
|
+
}
|
|
240
259
|
if (input.kind === "model" && !isValidModelAnswer(input, answer)) {
|
|
241
260
|
throw reject(
|
|
242
261
|
"invalid-answer",
|
|
@@ -328,8 +347,9 @@ const useCheckoutHost = (restored: unknown) => {
|
|
|
328
347
|
plan.decidedAt = Date.now();
|
|
329
348
|
plan.feedback = params.feedback.trim();
|
|
330
349
|
},
|
|
331
|
-
"checkout/cancel": () => {
|
|
350
|
+
"checkout/cancel": ({ id } = {}) => {
|
|
332
351
|
if (state.createdAt === null || isClosed(state)) return;
|
|
352
|
+
if (id !== undefined && id !== state.id) return;
|
|
333
353
|
state.status = "cancelled";
|
|
334
354
|
dismissOpenInputs();
|
|
335
355
|
},
|
|
@@ -383,11 +403,11 @@ const useCheckoutHost = (restored: unknown) => {
|
|
|
383
403
|
},
|
|
384
404
|
"agent/add-step": ({ title, detail, product, active }) => {
|
|
385
405
|
requireInstalling();
|
|
386
|
-
if (
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
406
|
+
if (product !== undefined && product.trim() === "") {
|
|
407
|
+
throw reject(
|
|
408
|
+
"invalid-input",
|
|
409
|
+
"a step's product identifier cannot be empty",
|
|
410
|
+
);
|
|
391
411
|
}
|
|
392
412
|
const id = `s${state.steps.length + 1}`;
|
|
393
413
|
state.steps.push({
|
package/src/index.ts
CHANGED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
isValidEntryPointAnswer,
|
|
4
|
+
isValidEntryPointInput,
|
|
5
|
+
parseEntryPointOptions,
|
|
6
|
+
type Checkout,
|
|
7
|
+
} from "./protocol";
|
|
8
|
+
|
|
9
|
+
const option: Checkout.EntryPointOption = {
|
|
10
|
+
id: "ticket-sidebar",
|
|
11
|
+
label: "Ticket sidebar",
|
|
12
|
+
description: "Draft replies beside the selected support ticket.",
|
|
13
|
+
entryPoint: {
|
|
14
|
+
formFactor: "sidebar",
|
|
15
|
+
placement: "The right side of the ticket detail page",
|
|
16
|
+
trigger: "Open with the Draft reply button in the ticket toolbar",
|
|
17
|
+
recommended: true,
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const input: Checkout.Input = {
|
|
22
|
+
id: "q1",
|
|
23
|
+
kind: "entry-point",
|
|
24
|
+
phase: "planning",
|
|
25
|
+
prompt: "Where should agents draft replies?",
|
|
26
|
+
options: [option],
|
|
27
|
+
default: option.id,
|
|
28
|
+
optional: false,
|
|
29
|
+
status: "open",
|
|
30
|
+
createdAt: 1,
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
describe("entry-point options", () => {
|
|
34
|
+
it("keeps contextual metadata and permits different placements of the same form factor", () => {
|
|
35
|
+
const second = {
|
|
36
|
+
...option,
|
|
37
|
+
id: "inbox-sidebar",
|
|
38
|
+
entryPoint: {
|
|
39
|
+
...option.entryPoint,
|
|
40
|
+
placement: "The inbox list",
|
|
41
|
+
trigger: "Open with the inbox Assist button",
|
|
42
|
+
recommended: false,
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
expect(parseEntryPointOptions([option, second])).toEqual([option, second]);
|
|
46
|
+
expect(isValidEntryPointInput(input)).toBe(true);
|
|
47
|
+
expect(isValidEntryPointAnswer(input, "ticket-sidebar")).toBe(true);
|
|
48
|
+
expect(isValidEntryPointAnswer(input, "sidebar")).toBe(false);
|
|
49
|
+
expect(isValidEntryPointAnswer(input, "ticket-sidebar:variant")).toBe(
|
|
50
|
+
false,
|
|
51
|
+
);
|
|
52
|
+
expect(isValidEntryPointAnswer(input, '["ticket-sidebar"]')).toBe(false);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it.each(
|
|
56
|
+
[
|
|
57
|
+
null,
|
|
58
|
+
{},
|
|
59
|
+
[],
|
|
60
|
+
[null],
|
|
61
|
+
[option, option],
|
|
62
|
+
[option, { ...option, id: "another" }],
|
|
63
|
+
[
|
|
64
|
+
option,
|
|
65
|
+
{ ...option, id: "b" },
|
|
66
|
+
{ ...option, id: "c" },
|
|
67
|
+
{ ...option, id: "d" },
|
|
68
|
+
],
|
|
69
|
+
[{ ...option, id: "with:variant" }],
|
|
70
|
+
[{ ...option, id: "x".repeat(65) }],
|
|
71
|
+
[{ ...option, label: " " }],
|
|
72
|
+
[{ ...option, description: "" }],
|
|
73
|
+
[{ ...option, description: "x".repeat(1001) }],
|
|
74
|
+
[{ ...option, entryPoint: null }],
|
|
75
|
+
[
|
|
76
|
+
{
|
|
77
|
+
...option,
|
|
78
|
+
entryPoint: { ...option.entryPoint, formFactor: "drawer" },
|
|
79
|
+
},
|
|
80
|
+
],
|
|
81
|
+
[{ ...option, entryPoint: { ...option.entryPoint, placement: "" } }],
|
|
82
|
+
[{ ...option, entryPoint: { ...option.entryPoint, trigger: " " } }],
|
|
83
|
+
[{ ...option, entryPoint: { ...option.entryPoint, recommended: "yes" } }],
|
|
84
|
+
[{ ...option, variants: [] }],
|
|
85
|
+
].map((value) => ({ value })),
|
|
86
|
+
)("rejects malformed or ambiguous choices %#", ({ value }) => {
|
|
87
|
+
expect(parseEntryPointOptions(value)).toBeUndefined();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("types an option with only the fields the parser keeps", () => {
|
|
91
|
+
const withIcon: Checkout.EntryPointOption = {
|
|
92
|
+
...option,
|
|
93
|
+
// @ts-expect-error an entry-point option carries no icon
|
|
94
|
+
icon: "sidebar",
|
|
95
|
+
};
|
|
96
|
+
const withVariants: Checkout.EntryPointOption = {
|
|
97
|
+
...option,
|
|
98
|
+
// @ts-expect-error an entry-point option carries no variants
|
|
99
|
+
variants: [],
|
|
100
|
+
};
|
|
101
|
+
expect(parseEntryPointOptions([withIcon])).toEqual([option]);
|
|
102
|
+
expect(parseEntryPointOptions([withVariants])).toBeUndefined();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("strips undeclared payload fields", () => {
|
|
106
|
+
expect(
|
|
107
|
+
parseEntryPointOptions([{ ...option, icon: "untrusted", extra: 1 }]),
|
|
108
|
+
).toEqual([option]);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("requires an exact default and a single selection", () => {
|
|
112
|
+
expect(isValidEntryPointInput({ ...input, multiple: true })).toBe(false);
|
|
113
|
+
expect(isValidEntryPointInput({ ...input, default: "unknown" })).toBe(
|
|
114
|
+
false,
|
|
115
|
+
);
|
|
116
|
+
expect(isValidEntryPointInput({ ...input, prompt: " " })).toBe(false);
|
|
117
|
+
expect(isValidEntryPointInput({ ...input, kind: "choice" })).toBe(false);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("refuses a help note, which the entry-point card has no place for", () => {
|
|
121
|
+
expect(
|
|
122
|
+
isValidEntryPointInput({
|
|
123
|
+
...input,
|
|
124
|
+
help: { summary: "Pick one" },
|
|
125
|
+
}),
|
|
126
|
+
).toBe(false);
|
|
127
|
+
});
|
|
128
|
+
});
|
package/src/protocol.ts
CHANGED
|
@@ -32,7 +32,26 @@ export namespace Checkout {
|
|
|
32
32
|
* another product depends on. The browser owns the catalog, so it answers
|
|
33
33
|
* with `checkout/add-product`; dismissing the input declines.
|
|
34
34
|
*/
|
|
35
|
-
export type InputKind =
|
|
35
|
+
export type InputKind =
|
|
36
|
+
| "text"
|
|
37
|
+
| "choice"
|
|
38
|
+
| "model"
|
|
39
|
+
| "product"
|
|
40
|
+
| "entry-point";
|
|
41
|
+
|
|
42
|
+
export type EntryPoint = {
|
|
43
|
+
formFactor: "modal" | "sidebar" | "full-page";
|
|
44
|
+
placement: string;
|
|
45
|
+
trigger: string;
|
|
46
|
+
recommended?: boolean;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export type EntryPointOption = {
|
|
50
|
+
id: string;
|
|
51
|
+
label: string;
|
|
52
|
+
description: string;
|
|
53
|
+
entryPoint: EntryPoint;
|
|
54
|
+
};
|
|
36
55
|
|
|
37
56
|
export type ChoiceVariant = { id: string; label: string };
|
|
38
57
|
|
|
@@ -44,6 +63,7 @@ export namespace Checkout {
|
|
|
44
63
|
icon?: string;
|
|
45
64
|
/** A second pick under this option, such as the language of a framework. */
|
|
46
65
|
variants?: ChoiceVariant[];
|
|
66
|
+
entryPoint?: EntryPoint;
|
|
47
67
|
};
|
|
48
68
|
|
|
49
69
|
/** A short nudge for users who need help choosing, with a longer guide to link to. */
|
|
@@ -199,7 +219,8 @@ export namespace Checkout {
|
|
|
199
219
|
"agent/ack": (params: { messageId: string }) => void;
|
|
200
220
|
"checkout/dismiss": (params: { inputId: string }) => void;
|
|
201
221
|
"checkout/plan": (params: PlanDecision) => void;
|
|
202
|
-
|
|
222
|
+
/** A cancel that names another checkout than the current one is a no-op. */
|
|
223
|
+
"checkout/cancel": (params?: { id?: string }) => void;
|
|
203
224
|
"checkout/finish": () => void;
|
|
204
225
|
"agent/intro": (params: { kind?: string }) => void;
|
|
205
226
|
"agent/hello": (params: { cwd?: string; kind?: string }) => void;
|
|
@@ -437,6 +458,76 @@ export const classifyChoiceAnswer = (
|
|
|
437
458
|
return kinds.includes("custom") ? "custom" : "option";
|
|
438
459
|
};
|
|
439
460
|
|
|
461
|
+
const isEntryPointText = (value: unknown, max: number): value is string =>
|
|
462
|
+
typeof value === "string" && value.trim() !== "" && value.length <= max;
|
|
463
|
+
|
|
464
|
+
export const parseEntryPointOptions = (
|
|
465
|
+
value: unknown,
|
|
466
|
+
): Checkout.EntryPointOption[] | undefined => {
|
|
467
|
+
if (!Array.isArray(value) || value.length < 1 || value.length > 3)
|
|
468
|
+
return undefined;
|
|
469
|
+
const options: Checkout.EntryPointOption[] = [];
|
|
470
|
+
for (const candidate of value) {
|
|
471
|
+
if (typeof candidate !== "object" || candidate === null) return undefined;
|
|
472
|
+
const { id, label, description, entryPoint, variants } =
|
|
473
|
+
candidate as Record<string, unknown>;
|
|
474
|
+
if (
|
|
475
|
+
typeof id !== "string" ||
|
|
476
|
+
!/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$/.test(id) ||
|
|
477
|
+
options.some((option) => option.id === id) ||
|
|
478
|
+
!isEntryPointText(label, 160) ||
|
|
479
|
+
!isEntryPointText(description, 1000) ||
|
|
480
|
+
variants !== undefined ||
|
|
481
|
+
typeof entryPoint !== "object" ||
|
|
482
|
+
entryPoint === null
|
|
483
|
+
)
|
|
484
|
+
return undefined;
|
|
485
|
+
const { formFactor, placement, trigger, recommended } =
|
|
486
|
+
entryPoint as Record<string, unknown>;
|
|
487
|
+
if (
|
|
488
|
+
(formFactor !== "modal" &&
|
|
489
|
+
formFactor !== "sidebar" &&
|
|
490
|
+
formFactor !== "full-page") ||
|
|
491
|
+
!isEntryPointText(placement, 400) ||
|
|
492
|
+
!isEntryPointText(trigger, 400) ||
|
|
493
|
+
(recommended !== undefined && typeof recommended !== "boolean")
|
|
494
|
+
)
|
|
495
|
+
return undefined;
|
|
496
|
+
options.push({
|
|
497
|
+
id,
|
|
498
|
+
label,
|
|
499
|
+
description,
|
|
500
|
+
entryPoint: {
|
|
501
|
+
formFactor,
|
|
502
|
+
placement,
|
|
503
|
+
trigger,
|
|
504
|
+
...(recommended !== undefined && { recommended }),
|
|
505
|
+
},
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
return options.filter((option) => option.entryPoint.recommended).length <= 1
|
|
509
|
+
? options
|
|
510
|
+
: undefined;
|
|
511
|
+
};
|
|
512
|
+
|
|
513
|
+
export const isValidEntryPointInput = (input: Checkout.InputSeed) => {
|
|
514
|
+
const options = parseEntryPointOptions(input.options);
|
|
515
|
+
return (
|
|
516
|
+
input.kind === "entry-point" &&
|
|
517
|
+
isEntryPointText(input.prompt, 1000) &&
|
|
518
|
+
options !== undefined &&
|
|
519
|
+
input.multiple === undefined &&
|
|
520
|
+
input.help === undefined &&
|
|
521
|
+
(input.default === undefined ||
|
|
522
|
+
options.some((option) => option.id === input.default))
|
|
523
|
+
);
|
|
524
|
+
};
|
|
525
|
+
|
|
526
|
+
export const isValidEntryPointAnswer = (
|
|
527
|
+
input: Checkout.Input,
|
|
528
|
+
answer: string,
|
|
529
|
+
) => input.options?.some((option) => option.id === answer) ?? false;
|
|
530
|
+
|
|
440
531
|
const REASONING_EFFORTS = new Set(["low", "medium", "high"]);
|
|
441
532
|
|
|
442
533
|
const isReasoningEffort = (
|