setup-agent 0.1.1 → 0.2.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 +16 -4
- package/dist/agents.d.ts.map +1 -1
- package/dist/browser.d.ts.map +1 -1
- package/dist/cli.d.ts +0 -20
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +90 -84
- package/dist/cli.js.map +1 -1
- package/dist/client.d.ts.map +1 -1
- package/dist/guide.d.ts +45 -0
- package/dist/guide.d.ts.map +1 -0
- package/dist/guide.js +133 -0
- package/dist/guide.js.map +1 -0
- package/dist/host.d.ts.map +1 -1
- package/dist/host.js +1 -1
- package/dist/host.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/needs.d.ts +4 -2
- package/dist/needs.d.ts.map +1 -1
- package/dist/needs.js +20 -17
- package/dist/needs.js.map +1 -1
- package/dist/presets.d.ts.map +1 -1
- package/dist/protocol.d.ts +1 -1
- package/dist/protocol.d.ts.map +1 -1
- package/dist/sdk.d.ts.map +1 -1
- package/dist/sdk.js +19 -24
- package/dist/sdk.js.map +1 -1
- package/dist/stream.d.ts +8 -4
- package/dist/stream.d.ts.map +1 -1
- package/dist/stream.js +28 -17
- package/dist/stream.js.map +1 -1
- package/package.json +4 -4
- package/src/cli.test.ts +129 -116
- package/src/cli.ts +145 -132
- package/src/guide.test.ts +246 -0
- package/src/guide.ts +177 -0
- package/src/host.ts +1 -1
- package/src/index.ts +2 -0
- package/src/needs.test.ts +4 -5
- package/src/needs.ts +38 -23
- package/src/sdk.test.ts +8 -12
- package/src/sdk.ts +21 -31
- package/src/stream.test.ts +9 -7
- package/src/stream.ts +50 -23
- package/src/tests/guide-fixture.ts +39 -0
- package/src/tests/setup.ts +15 -0
package/src/guide.ts
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The guide: every text the CLI shows the coding agent and the order of its
|
|
3
|
+
* intro, fetched from the setup server at `<setup-url>/guide` so a wording
|
|
4
|
+
* or flow fix ships with a server deploy. Setup is online-only, and the CLI
|
|
5
|
+
* keeps no copy of the text.
|
|
6
|
+
*
|
|
7
|
+
* The guide is data. The CLI prints its texts after stripping control
|
|
8
|
+
* characters, fills `{{name}}` placeholders with values it computes itself,
|
|
9
|
+
* and runs intro steps only from `LOCAL_ACTIONS`. Commands it runs, files it
|
|
10
|
+
* writes and URLs it opens never come from the guide.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** The guide format the CLI reads. Bump it only for a change an older CLI cannot read. */
|
|
14
|
+
export const GUIDE_PROTOCOL = 1;
|
|
15
|
+
|
|
16
|
+
/** Every text the CLI prints from the guide. */
|
|
17
|
+
export const GUIDE_TEXT_KEYS = [
|
|
18
|
+
"briefing",
|
|
19
|
+
"intro.status-heading",
|
|
20
|
+
"stream.briefing",
|
|
21
|
+
"stream.briefing-footer",
|
|
22
|
+
"stream.stay-connected",
|
|
23
|
+
"stream.opened.input",
|
|
24
|
+
"stream.opened.plan",
|
|
25
|
+
"stream.opened.finish",
|
|
26
|
+
"stream.ask.recently-opened",
|
|
27
|
+
"stream.ask.browser-closed",
|
|
28
|
+
"stream.ask.no-browser",
|
|
29
|
+
"wait.waiting",
|
|
30
|
+
"done.next",
|
|
31
|
+
"need.plan.question",
|
|
32
|
+
"need.plan.approve",
|
|
33
|
+
"need.plan.revise",
|
|
34
|
+
"need.plan.format",
|
|
35
|
+
"need.finish.question",
|
|
36
|
+
"need.finish.question-preview",
|
|
37
|
+
"need.finish.close",
|
|
38
|
+
"need.finish.format",
|
|
39
|
+
"need.input.empty-prompt",
|
|
40
|
+
"need.input.optional",
|
|
41
|
+
"need.input.model",
|
|
42
|
+
"need.product.add",
|
|
43
|
+
"need.product.decline",
|
|
44
|
+
"need.format.text",
|
|
45
|
+
"need.format.product",
|
|
46
|
+
"need.format.model",
|
|
47
|
+
"need.format.entry-point",
|
|
48
|
+
"need.format.choice",
|
|
49
|
+
"need.format.multiple",
|
|
50
|
+
] as const;
|
|
51
|
+
export type GuideTextKey = (typeof GUIDE_TEXT_KEYS)[number];
|
|
52
|
+
|
|
53
|
+
/** All a guide can make the CLI do: print one of its texts, print the setup's state, or announce the agent. */
|
|
54
|
+
const LOCAL_ACTIONS = ["print", "print-status", "announce-agent"] as const;
|
|
55
|
+
|
|
56
|
+
export type GuideStep =
|
|
57
|
+
| { action: "print"; text: GuideTextKey }
|
|
58
|
+
| { action: "print-status" }
|
|
59
|
+
| { action: "announce-agent" };
|
|
60
|
+
|
|
61
|
+
export type Guide = {
|
|
62
|
+
protocol: typeof GUIDE_PROTOCOL;
|
|
63
|
+
texts: Record<GuideTextKey, string>;
|
|
64
|
+
intro: GuideStep[];
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/** Who started the setup (the user's browser, `assistant-ui setup`, or that command as the stream) and whether a browser opens. */
|
|
68
|
+
export type GuideContext = {
|
|
69
|
+
started: "user" | "agent" | "agent-stream";
|
|
70
|
+
opened: boolean;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const MAX_GUIDE_BYTES = 256 * 1024;
|
|
74
|
+
const TIMEOUT_MS = 5_000;
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* `text` without control characters other than tab and line feed (an escape
|
|
78
|
+
* sequence could rewrite the terminal) and bidirectional overrides.
|
|
79
|
+
*/
|
|
80
|
+
const sanitizeTerminalText = (text: string) =>
|
|
81
|
+
// oxlint-disable-next-line no-control-regex
|
|
82
|
+
text.replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f--]/g, "");
|
|
83
|
+
|
|
84
|
+
/** A guide text with its placeholders filled once; a value is never read for placeholders itself. */
|
|
85
|
+
export const renderText = (
|
|
86
|
+
guide: Pick<Guide, "texts">,
|
|
87
|
+
key: GuideTextKey,
|
|
88
|
+
values: Record<string, string | number> = {},
|
|
89
|
+
) =>
|
|
90
|
+
guide.texts[key].replace(/\{\{([A-Za-z]+)\}\}/g, (_, name: string) =>
|
|
91
|
+
String(values[name] ?? ""),
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
const isTextKey = (value: unknown): value is GuideTextKey =>
|
|
95
|
+
(GUIDE_TEXT_KEYS as readonly unknown[]).includes(value);
|
|
96
|
+
|
|
97
|
+
/** The guide the server sent, or an error saying what this CLI cannot use. */
|
|
98
|
+
export const parseGuide = (value: unknown): Guide => {
|
|
99
|
+
const guide = value as Partial<Record<keyof Guide, unknown>> | null;
|
|
100
|
+
if (guide?.protocol !== GUIDE_PROTOCOL)
|
|
101
|
+
throw new Error(`the guide is not protocol ${GUIDE_PROTOCOL}`);
|
|
102
|
+
const texts = guide.texts as Record<string, unknown> | undefined;
|
|
103
|
+
const parsed = {} as Record<GuideTextKey, string>;
|
|
104
|
+
for (const key of GUIDE_TEXT_KEYS) {
|
|
105
|
+
const text = texts?.[key];
|
|
106
|
+
if (typeof text !== "string") throw new Error(`the guide has no "${key}"`);
|
|
107
|
+
parsed[key] = sanitizeTerminalText(text);
|
|
108
|
+
}
|
|
109
|
+
if (!Array.isArray(guide.intro)) throw new Error("the guide has no intro");
|
|
110
|
+
const intro = guide.intro.map((step: unknown): GuideStep => {
|
|
111
|
+
const { action, text, ...rest } = (step ?? {}) as Record<string, unknown>;
|
|
112
|
+
const allowed =
|
|
113
|
+
(LOCAL_ACTIONS as readonly unknown[]).includes(action) &&
|
|
114
|
+
Object.keys(rest).length === 0 &&
|
|
115
|
+
(action === "print" ? isTextKey(text) : text === undefined);
|
|
116
|
+
if (!allowed)
|
|
117
|
+
throw new Error(
|
|
118
|
+
`the guide asks for an action this CLI does not run: ${sanitizeTerminalText(JSON.stringify(step) ?? "").slice(0, 80)}`,
|
|
119
|
+
);
|
|
120
|
+
return action === "print"
|
|
121
|
+
? { action, text: text as GuideTextKey }
|
|
122
|
+
: { action: action as "print-status" | "announce-agent" };
|
|
123
|
+
});
|
|
124
|
+
return { protocol: GUIDE_PROTOCOL, texts: parsed, intro };
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* The guide for this setup, from `<setupUrl>/guide`. Setup is online-only:
|
|
129
|
+
* without the guide the command fails. Redirects are refused, so the guide
|
|
130
|
+
* only ever comes from the setup URL `parseSetupUrl` accepted.
|
|
131
|
+
*/
|
|
132
|
+
export const loadGuide = async (
|
|
133
|
+
setupUrl: string,
|
|
134
|
+
{ started, opened }: GuideContext,
|
|
135
|
+
): Promise<Guide> => {
|
|
136
|
+
const query = new URLSearchParams({
|
|
137
|
+
protocol: String(GUIDE_PROTOCOL),
|
|
138
|
+
started,
|
|
139
|
+
opened: opened ? "1" : "0",
|
|
140
|
+
});
|
|
141
|
+
const offline = `Setup needs internet access to ${setupUrl}`;
|
|
142
|
+
let response: Response;
|
|
143
|
+
try {
|
|
144
|
+
response = await fetch(`${setupUrl.replace(/\/$/, "")}/guide?${query}`, {
|
|
145
|
+
redirect: "error",
|
|
146
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
147
|
+
});
|
|
148
|
+
} catch {
|
|
149
|
+
throw new Error(offline);
|
|
150
|
+
}
|
|
151
|
+
if (response.status === 426) {
|
|
152
|
+
const { supported = [] } = (await response.json().catch(() => ({}))) as {
|
|
153
|
+
supported?: unknown[];
|
|
154
|
+
};
|
|
155
|
+
throw new Error(
|
|
156
|
+
supported.some((v) => typeof v === "number" && v > GUIDE_PROTOCOL)
|
|
157
|
+
? "this setup-agent is older than the setup server supports; run the latest setup-agent"
|
|
158
|
+
: "this setup-agent is newer than the setup server; try again once the server updates",
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
if (!response.ok) throw new Error(`${offline} (HTTP ${response.status})`);
|
|
162
|
+
try {
|
|
163
|
+
let body = "";
|
|
164
|
+
let bytes = 0;
|
|
165
|
+
const decoder = new TextDecoder();
|
|
166
|
+
for await (const chunk of response.body ?? []) {
|
|
167
|
+
bytes += chunk.byteLength;
|
|
168
|
+
if (bytes > MAX_GUIDE_BYTES) throw new Error("it is too large");
|
|
169
|
+
body += decoder.decode(chunk, { stream: true });
|
|
170
|
+
}
|
|
171
|
+
return parseGuide(JSON.parse(body + decoder.decode()));
|
|
172
|
+
} catch (error) {
|
|
173
|
+
throw new Error(
|
|
174
|
+
`the setup server sent a guide this setup-agent cannot use (${error instanceof Error ? error.message : String(error)})`,
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
};
|
package/src/host.ts
CHANGED
|
@@ -50,7 +50,7 @@ const useCheckoutHost = (restored: unknown) => {
|
|
|
50
50
|
if (state.status === "waiting") {
|
|
51
51
|
throw reject(
|
|
52
52
|
"planning-not-started",
|
|
53
|
-
'
|
|
53
|
+
'the setup page starts planning on its own once the user opens it; wait with "wait start"',
|
|
54
54
|
);
|
|
55
55
|
}
|
|
56
56
|
};
|
package/src/index.ts
CHANGED
|
@@ -28,3 +28,5 @@ export { INPUT_PRESETS, isPresetId, presetInput } from "./presets";
|
|
|
28
28
|
export type { PresetId, PresetOverrides } from "./presets";
|
|
29
29
|
export { connectCheckout } from "./client";
|
|
30
30
|
export type { CheckoutClient } from "./client";
|
|
31
|
+
export { GUIDE_PROTOCOL, parseGuide } from "./guide";
|
|
32
|
+
export type { Guide, GuideContext, GuideStep, GuideTextKey } from "./guide";
|
package/src/needs.test.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { testGuide } from "./tests/guide-fixture";
|
|
1
2
|
import { describe, expect, it } from "vitest";
|
|
2
3
|
import {
|
|
3
4
|
answerNeed,
|
|
@@ -70,7 +71,7 @@ describe("terminalQuestion", () => {
|
|
|
70
71
|
presetInput("framework") as Checkout.Input,
|
|
71
72
|
presetInput("llm-provider") as Checkout.Input,
|
|
72
73
|
),
|
|
73
|
-
).map(terminalQuestion);
|
|
74
|
+
).map((need) => terminalQuestion(need, testGuide));
|
|
74
75
|
expect(framework!.options?.map((option) => option.id)).toEqual([
|
|
75
76
|
"ai-sdk:typescript",
|
|
76
77
|
"mastra:typescript",
|
|
@@ -80,10 +81,8 @@ describe("terminalQuestion", () => {
|
|
|
80
81
|
expect(formatTerminalQuestion(framework!)).toMatch(
|
|
81
82
|
/^Which agent framework runs your backend\? Options: ai-sdk:typescript \(Vercel AI SDK \(TypeScript\): Shortest path/,
|
|
82
83
|
);
|
|
83
|
-
expect(model!.question).toContain("
|
|
84
|
-
expect(model!.answerFormat).toBe(
|
|
85
|
-
"<provider>:<model>, such as openai:gpt-5",
|
|
86
|
-
);
|
|
84
|
+
expect(model!.question).toContain("[need.input.model]");
|
|
85
|
+
expect(model!.answerFormat).toBe("[need.format.model]");
|
|
87
86
|
});
|
|
88
87
|
});
|
|
89
88
|
|
package/src/needs.ts
CHANGED
|
@@ -11,6 +11,8 @@ import {
|
|
|
11
11
|
parseMultipleAnswer,
|
|
12
12
|
} from "./protocol";
|
|
13
13
|
import type { Checkout } from "./protocol";
|
|
14
|
+
import { renderText } from "./guide";
|
|
15
|
+
import type { Guide, GuideTextKey } from "./guide";
|
|
14
16
|
|
|
15
17
|
/**
|
|
16
18
|
* Something the setup waits on the user for: an open question, a proposed
|
|
@@ -86,49 +88,62 @@ const optionList = (input: Checkout.Input): NeedOption[] =>
|
|
|
86
88
|
: [entry(option.id, option.label)];
|
|
87
89
|
});
|
|
88
90
|
|
|
89
|
-
const ANSWER_FORMATS
|
|
90
|
-
text: "
|
|
91
|
-
product: "
|
|
92
|
-
model: "
|
|
93
|
-
"entry-point": "
|
|
94
|
-
choice: "
|
|
95
|
-
}
|
|
91
|
+
const ANSWER_FORMATS = {
|
|
92
|
+
text: "need.format.text",
|
|
93
|
+
product: "need.format.product",
|
|
94
|
+
model: "need.format.model",
|
|
95
|
+
"entry-point": "need.format.entry-point",
|
|
96
|
+
choice: "need.format.choice",
|
|
97
|
+
} as const satisfies Record<Checkout.InputKind, GuideTextKey>;
|
|
96
98
|
|
|
97
|
-
|
|
99
|
+
/** A need as a question for the agent's own conversation, worded by the setup server's `guide`. */
|
|
100
|
+
export const terminalQuestion = (
|
|
101
|
+
need: SetupNeed,
|
|
102
|
+
guide: Pick<Guide, "texts">,
|
|
103
|
+
): TerminalQuestion => {
|
|
104
|
+
const text = (key: GuideTextKey, values?: Record<string, string>) =>
|
|
105
|
+
renderText(guide, key, values);
|
|
98
106
|
if (need.kind === "plan")
|
|
99
107
|
return {
|
|
100
|
-
question:
|
|
101
|
-
"Approve the setup plan? Show the user the plan you proposed and ask whether to go ahead or what to change.",
|
|
108
|
+
question: text("need.plan.question"),
|
|
102
109
|
options: [
|
|
103
|
-
{ id: "approve", label: "
|
|
104
|
-
{ id: "revise", label: "
|
|
110
|
+
{ id: "approve", label: text("need.plan.approve") },
|
|
111
|
+
{ id: "revise", label: text("need.plan.revise") },
|
|
105
112
|
],
|
|
106
|
-
answerFormat:
|
|
113
|
+
answerFormat: text("need.plan.format"),
|
|
107
114
|
};
|
|
108
115
|
if (need.kind === "finish")
|
|
109
116
|
return {
|
|
110
|
-
question:
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
117
|
+
question: need.completion.preview
|
|
118
|
+
? text("need.finish.question-preview", {
|
|
119
|
+
preview: need.completion.preview,
|
|
120
|
+
})
|
|
121
|
+
: text("need.finish.question"),
|
|
122
|
+
options: [{ id: "close", label: text("need.finish.close") }],
|
|
123
|
+
answerFormat: text("need.finish.format"),
|
|
114
124
|
};
|
|
115
125
|
const { input } = need;
|
|
116
126
|
const options =
|
|
117
127
|
input.kind === "product"
|
|
118
128
|
? [
|
|
119
|
-
{
|
|
120
|
-
|
|
129
|
+
{
|
|
130
|
+
id: "add",
|
|
131
|
+
label: text("need.product.add", {
|
|
132
|
+
product: input.product ?? "the product",
|
|
133
|
+
}),
|
|
134
|
+
},
|
|
135
|
+
{ id: "decline", label: text("need.product.decline") },
|
|
121
136
|
]
|
|
122
137
|
: input.kind === "text"
|
|
123
138
|
? undefined
|
|
124
139
|
: optionList(input);
|
|
125
140
|
return {
|
|
126
|
-
question: `${input.prompt.trim() || "
|
|
141
|
+
question: `${input.prompt.trim() || text("need.input.empty-prompt")}${input.optional ? text("need.input.optional") : ""}${input.kind === "model" ? text("need.input.model") : ""}`,
|
|
127
142
|
...(options && { options }),
|
|
128
143
|
...(input.multiple && { multiple: true as const }),
|
|
129
|
-
answerFormat:
|
|
130
|
-
? "
|
|
131
|
-
|
|
144
|
+
answerFormat: text(
|
|
145
|
+
input.multiple ? "need.format.multiple" : ANSWER_FORMATS[input.kind],
|
|
146
|
+
),
|
|
132
147
|
};
|
|
133
148
|
};
|
|
134
149
|
|
package/src/sdk.test.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, expect, it, vi } from "vitest";
|
|
2
2
|
import * as sdk from "./sdk";
|
|
3
|
-
import { initialCheckoutState } from "./protocol";
|
|
3
|
+
import { OPTION_ICONS, initialCheckoutState } from "./protocol";
|
|
4
4
|
import type { Checkout } from "./protocol";
|
|
5
5
|
import type { CheckoutClient } from "./client";
|
|
6
6
|
|
|
@@ -62,18 +62,14 @@ describe("setup-agent/sdk", () => {
|
|
|
62
62
|
agent: { cwd: "/app" },
|
|
63
63
|
},
|
|
64
64
|
});
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
65
|
+
// The briefing is the server's guide, with this command's values filled in.
|
|
66
|
+
const url = `https://checkout.test/${session.linkId}`;
|
|
67
|
+
expect(out).toBe(
|
|
68
|
+
`[stream.briefing] my-tool setup npx my-tool setup ${url} ${session.browserUrl}[briefing] npx my-tool setup ${url} ${OPTION_ICONS.join(", ")}\n[stream.briefing-footer] ${session.browserUrl}${JSON.stringify({ status: "planning", products: [{ slug: "assistant-ui", name: "assistant-ui" }] })}\n`,
|
|
69
|
+
);
|
|
70
|
+
expect(String(vi.mocked(fetch).mock.calls[0]?.[0])).toBe(
|
|
71
|
+
`${url}/guide?protocol=1&started=agent-stream&opened=1`,
|
|
68
72
|
);
|
|
69
|
-
expect(out).toContain("run_in_background");
|
|
70
|
-
expect(out).toContain('"input.needed"');
|
|
71
|
-
expect(out).toContain('"browser.opened"');
|
|
72
|
-
expect(out).not.toContain("start a persistent Monitor");
|
|
73
|
-
expect(out).toContain("opens the user's browser by itself");
|
|
74
|
-
expect(out).not.toContain("npx setup-agent");
|
|
75
|
-
expect(out).not.toContain("click Begin plan");
|
|
76
|
-
expect(out).toContain(session.browserUrl);
|
|
77
73
|
expect(open).not.toHaveBeenCalled();
|
|
78
74
|
expect(calls.map(([name]) => name)).toEqual([
|
|
79
75
|
"checkout/create",
|
package/src/sdk.ts
CHANGED
|
@@ -5,7 +5,9 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { randomInt } from "node:crypto";
|
|
7
7
|
import { detectAgentKind } from "./agents";
|
|
8
|
-
import {
|
|
8
|
+
import { OPTION_ICONS } from "./protocol";
|
|
9
|
+
import { loadGuide, renderText } from "./guide";
|
|
10
|
+
import type { Guide } from "./guide";
|
|
9
11
|
import { connectCheckout } from "./client";
|
|
10
12
|
import type { CheckoutClient } from "./client";
|
|
11
13
|
import type { Checkout } from "./protocol";
|
|
@@ -57,37 +59,19 @@ export type RunAgentSetupOptions = {
|
|
|
57
59
|
/** What the agent reads first in the output of a setup command that keeps running. */
|
|
58
60
|
const setupBriefing = (
|
|
59
61
|
session: SetupSession,
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
name = "Setup",
|
|
63
|
-
canOpen = true,
|
|
64
|
-
}: Pick<RunAgentSetupOptions, "command" | "name" | "canOpen">,
|
|
62
|
+
guide: Guide,
|
|
63
|
+
{ command, name = "Setup" }: Pick<RunAgentSetupOptions, "command" | "name">,
|
|
65
64
|
) => {
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
2. Read its new output as it arrives. Each line after this briefing is a JSON array of events.
|
|
72
|
-
3. Run every other command in another shell: ${command} ${url} <command>.
|
|
73
|
-
4. ${
|
|
74
|
-
canOpen
|
|
75
|
-
? "Setup opens the user's browser by itself when you ask the user something; do not open it yourself."
|
|
76
|
-
: `Setup will not open a browser (--no-open). Give the user ${session.browserUrl} once, or ask in your own conversation.`
|
|
77
|
-
} On an "input.needed" event, do what its message says; to answer from here, run its "answer" command with the user's reply.
|
|
78
|
-
|
|
79
|
-
${agentInstructions(session.agentUrl, {
|
|
80
|
-
command,
|
|
81
|
-
startedByAgent: {
|
|
65
|
+
const values = {
|
|
66
|
+
name,
|
|
67
|
+
cmd: command,
|
|
68
|
+
url: shellWord(session.agentUrl),
|
|
69
|
+
linkId: session.linkId,
|
|
82
70
|
browserUrl: session.browserUrl,
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
},
|
|
86
|
-
})}
|
|
87
|
-
Setup page, if the user asks for it: ${session.browserUrl}
|
|
88
|
-
|
|
89
|
-
Current setup:
|
|
90
|
-
${JSON.stringify({ status: "planning", products: session.products })}
|
|
71
|
+
icons: OPTION_ICONS.join(", "),
|
|
72
|
+
};
|
|
73
|
+
return `${renderText(guide, "stream.briefing", values)}${renderText(guide, "briefing", values)}
|
|
74
|
+
${renderText(guide, "stream.briefing-footer", values)}${JSON.stringify({ status: "planning", products: session.products })}
|
|
91
75
|
`;
|
|
92
76
|
};
|
|
93
77
|
|
|
@@ -103,6 +87,11 @@ export const runAgentSetup = async (options: RunAgentSetupOptions) => {
|
|
|
103
87
|
const linkId = createSetupId();
|
|
104
88
|
const setupId = createSetupId();
|
|
105
89
|
const agentUrl = `${options.server.replace(/\/$/, "")}/${linkId}`;
|
|
90
|
+
// Online-only: the briefing and the stream's wording come from the setup server.
|
|
91
|
+
const guide = await loadGuide(agentUrl, {
|
|
92
|
+
started: "agent-stream",
|
|
93
|
+
opened: options.canOpen !== false,
|
|
94
|
+
});
|
|
106
95
|
const client = await connect(agentUrl);
|
|
107
96
|
try {
|
|
108
97
|
const kind = detectAgentKind(options.env);
|
|
@@ -124,9 +113,10 @@ export const runAgentSetup = async (options: RunAgentSetupOptions) => {
|
|
|
124
113
|
};
|
|
125
114
|
options.onSession?.(session);
|
|
126
115
|
(options.write ?? ((text) => process.stdout.write(text)))(
|
|
127
|
-
setupBriefing(session, options),
|
|
116
|
+
setupBriefing(session, guide, options),
|
|
128
117
|
);
|
|
129
118
|
const stream = await followSetup(client, {
|
|
119
|
+
guide,
|
|
130
120
|
...options.stream,
|
|
131
121
|
agent: { ...(kind !== undefined && { kind }), ...options.stream?.agent },
|
|
132
122
|
browser: {
|
package/src/stream.test.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { testGuide } from "./tests/guide-fixture";
|
|
1
2
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
3
|
import { answerNeed } from "./needs";
|
|
3
4
|
import { initialCheckoutState } from "./protocol";
|
|
@@ -7,6 +8,7 @@ import type { StreamEvent } from "./stream";
|
|
|
7
8
|
|
|
8
9
|
const PAGE = "https://setup.test/#link=abc&setup=s1";
|
|
9
10
|
const ANSWER = "npx assistant-ui@latest setup https://checkout.test/abc answer";
|
|
11
|
+
const WAIT = "npx assistant-ui@latest setup https://checkout.test/abc wait";
|
|
10
12
|
|
|
11
13
|
/** A client whose state the test changes, notifying like a statewire client. */
|
|
12
14
|
const fakeClient = () => {
|
|
@@ -91,6 +93,7 @@ describe("followSetup", () => {
|
|
|
91
93
|
const lines: StreamEvent[][] = [];
|
|
92
94
|
const open = vi.fn();
|
|
93
95
|
const stream = await followSetup(fake.client, {
|
|
96
|
+
guide: testGuide,
|
|
94
97
|
print: (events) => lines.push(events),
|
|
95
98
|
agent: { cwd: "/app", kind: "claude" },
|
|
96
99
|
browser: {
|
|
@@ -129,8 +132,7 @@ describe("followSetup", () => {
|
|
|
129
132
|
event: "browser.opened",
|
|
130
133
|
id: "q1",
|
|
131
134
|
url: PAGE,
|
|
132
|
-
message:
|
|
133
|
-
'Opened the setup in the user\'s browser for "Which package manager?". Tell the user once that it is waiting there, and give them https://setup.test/#link=abc&setup=s1 in case it did not open. The answer arrives on this stream.',
|
|
135
|
+
message: `[stream.opened.input] Which package manager? ${PAGE} with "${WAIT} q1"`,
|
|
134
136
|
},
|
|
135
137
|
]);
|
|
136
138
|
|
|
@@ -160,15 +162,14 @@ describe("followSetup", () => {
|
|
|
160
162
|
event: "input.needed",
|
|
161
163
|
id: "q3",
|
|
162
164
|
reason: "recently-opened",
|
|
163
|
-
message:
|
|
164
|
-
"A browser tab for setup was opened 10s ago. Wait for the user there, or ask the user directly in the terminal: Which registry? Options: pnpm (pnpm), npm (npm).",
|
|
165
|
+
message: `[stream.ask.recently-opened] 10 with "${WAIT} q3" Which registry? Options: pnpm (pnpm), npm (npm).`,
|
|
165
166
|
question: "Which registry?",
|
|
166
167
|
options: [
|
|
167
168
|
{ id: "pnpm", label: "pnpm" },
|
|
168
169
|
{ id: "npm", label: "npm" },
|
|
169
170
|
],
|
|
170
|
-
answerFormat: "
|
|
171
|
-
answer: `${ANSWER} q3 <
|
|
171
|
+
answerFormat: "[need.format.choice]",
|
|
172
|
+
answer: `${ANSWER} q3 <[need.format.choice]>`,
|
|
172
173
|
});
|
|
173
174
|
|
|
174
175
|
// The agent asked in its own conversation and answers for the user.
|
|
@@ -196,7 +197,7 @@ describe("followSetup", () => {
|
|
|
196
197
|
id: "q1",
|
|
197
198
|
reason: "browser-closed",
|
|
198
199
|
message:
|
|
199
|
-
"
|
|
200
|
+
"[stream.ask.browser-closed] Which package manager? Options: pnpm (pnpm), npm (npm).",
|
|
200
201
|
});
|
|
201
202
|
// A question right after: inside the 30s window, and the tab is known to be gone.
|
|
202
203
|
t.ask("Which registry?");
|
|
@@ -212,6 +213,7 @@ describe("followSetup", () => {
|
|
|
212
213
|
const fake = fakeClient();
|
|
213
214
|
const lines: StreamEvent[][] = [];
|
|
214
215
|
const stream = await followSetup(fake.client, {
|
|
216
|
+
guide: testGuide,
|
|
215
217
|
print: (events) => lines.push(events),
|
|
216
218
|
agent: { cwd: "/app" },
|
|
217
219
|
});
|
package/src/stream.ts
CHANGED
|
@@ -11,6 +11,8 @@ import {
|
|
|
11
11
|
parseMultipleAnswer,
|
|
12
12
|
} from "./protocol";
|
|
13
13
|
import type { Checkout } from "./protocol";
|
|
14
|
+
import { renderText } from "./guide";
|
|
15
|
+
import type { Guide } from "./guide";
|
|
14
16
|
|
|
15
17
|
export type StreamEvent =
|
|
16
18
|
| { event: "planning.started" }
|
|
@@ -47,14 +49,14 @@ export type StreamEvent =
|
|
|
47
49
|
answer: string;
|
|
48
50
|
} & TerminalQuestion);
|
|
49
51
|
|
|
50
|
-
|
|
51
|
-
"This setup is over, not your connection. Keep the stream running, tell the user you are connected and waiting for their next setup, and run wait start.";
|
|
52
|
-
|
|
52
|
+
/** The events between two states, with hints worded by the setup server's `guide`. */
|
|
53
53
|
export const diffEvents = (
|
|
54
54
|
before: Checkout.State,
|
|
55
55
|
after: Checkout.State,
|
|
56
|
+
guide: Pick<Guide, "texts">,
|
|
56
57
|
): StreamEvent[] => {
|
|
57
58
|
const events: StreamEvent[] = [];
|
|
59
|
+
const stayConnected = renderText(guide, "stream.stay-connected");
|
|
58
60
|
if (before.status === "waiting" && after.status === "planning") {
|
|
59
61
|
events.push({ event: "planning.started" });
|
|
60
62
|
}
|
|
@@ -110,10 +112,10 @@ export const diffEvents = (
|
|
|
110
112
|
}
|
|
111
113
|
}
|
|
112
114
|
if (before.status !== "done" && after.status === "done") {
|
|
113
|
-
events.push({ event: "finished", next:
|
|
115
|
+
events.push({ event: "finished", next: stayConnected });
|
|
114
116
|
}
|
|
115
117
|
if (before.status !== "cancelled" && after.status === "cancelled") {
|
|
116
|
-
events.push({ event: "cancelled", next:
|
|
118
|
+
events.push({ event: "cancelled", next: stayConnected });
|
|
117
119
|
}
|
|
118
120
|
if (after.id !== null && after.id !== before.id) {
|
|
119
121
|
events.push({
|
|
@@ -165,40 +167,57 @@ export const shellWord = (value: string) =>
|
|
|
165
167
|
? value
|
|
166
168
|
: `'${value.replaceAll("'", `'\\''`)}'`;
|
|
167
169
|
|
|
168
|
-
const
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
"
|
|
177
|
-
|
|
178
|
-
|
|
170
|
+
const ASK_TEXTS = {
|
|
171
|
+
"recently-opened": "stream.ask.recently-opened",
|
|
172
|
+
"browser-closed": "stream.ask.browser-closed",
|
|
173
|
+
"no-browser": "stream.ask.no-browser",
|
|
174
|
+
} as const satisfies Record<AskReason, string>;
|
|
175
|
+
|
|
176
|
+
/** The command that blocks on a need, beside the one that answers it: `<cmd> <url> wait`. */
|
|
177
|
+
const waitCommandFor = (answerCommand: string) =>
|
|
178
|
+
answerCommand.endsWith(" answer")
|
|
179
|
+
? `${answerCommand.slice(0, -" answer".length)} wait`
|
|
180
|
+
: undefined;
|
|
179
181
|
|
|
180
182
|
/** What the agent reads when setup opened the browser, or when it should ask the user itself. */
|
|
181
183
|
const gateEvent = (
|
|
182
184
|
action: BrowserGateAction,
|
|
183
185
|
answerCommand: string,
|
|
184
186
|
page: string,
|
|
187
|
+
waitCommand: string | undefined,
|
|
188
|
+
guide: Pick<Guide, "texts">,
|
|
185
189
|
): StreamEvent => {
|
|
186
190
|
const { need } = action;
|
|
191
|
+
// Closing the setup is the user's call after "done"; there is nothing to block on.
|
|
192
|
+
const wait =
|
|
193
|
+
waitCommand !== undefined && need.kind !== "finish"
|
|
194
|
+
? ` with "${waitCommand} ${need.id}"`
|
|
195
|
+
: "";
|
|
187
196
|
if (action.type === "open")
|
|
188
197
|
return {
|
|
189
198
|
event: "browser.opened",
|
|
190
199
|
id: need.id,
|
|
191
200
|
url: action.url,
|
|
192
|
-
message: `
|
|
201
|
+
message: renderText(guide, `stream.opened.${need.kind}`, {
|
|
202
|
+
url: action.url,
|
|
203
|
+
wait: wait || ' with "wait"',
|
|
204
|
+
prompt:
|
|
205
|
+
need.kind === "input"
|
|
206
|
+
? need.input.prompt.trim() ||
|
|
207
|
+
renderText(guide, "need.input.empty-prompt")
|
|
208
|
+
: "",
|
|
209
|
+
}),
|
|
193
210
|
};
|
|
194
|
-
const question = terminalQuestion(need);
|
|
211
|
+
const question = terminalQuestion(need, guide);
|
|
195
212
|
return {
|
|
196
213
|
event: "input.needed",
|
|
197
214
|
id: need.id,
|
|
198
215
|
reason: action.reason,
|
|
199
|
-
message:
|
|
216
|
+
message: renderText(guide, ASK_TEXTS[action.reason], {
|
|
217
|
+
question: formatTerminalQuestion(question),
|
|
200
218
|
agoSeconds: Math.round((action.openedAgoMs ?? 0) / 1000),
|
|
201
219
|
page,
|
|
220
|
+
wait,
|
|
202
221
|
}),
|
|
203
222
|
...question,
|
|
204
223
|
answer: `${answerCommand} ${need.id} <${question.answerFormat}>`,
|
|
@@ -206,6 +225,8 @@ const gateEvent = (
|
|
|
206
225
|
};
|
|
207
226
|
|
|
208
227
|
export type FollowSetupOptions = {
|
|
228
|
+
/** The setup server's wording for the stream's messages. */
|
|
229
|
+
guide: Pick<Guide, "texts">;
|
|
209
230
|
/** Writes one line of events; the default prints a JSON array to stdout. */
|
|
210
231
|
print?: (events: StreamEvent[]) => void;
|
|
211
232
|
/** The agent the stream reports in its hello; detected by default. */
|
|
@@ -231,7 +252,7 @@ export type FollowSetupOptions = {
|
|
|
231
252
|
*/
|
|
232
253
|
export const followSetup = async (
|
|
233
254
|
client: Pick<CheckoutClient, "state" | "subscribe" | "commands">,
|
|
234
|
-
{ print, agent, browser }: FollowSetupOptions
|
|
255
|
+
{ print, agent, browser, guide }: FollowSetupOptions,
|
|
235
256
|
) => {
|
|
236
257
|
const write =
|
|
237
258
|
print ??
|
|
@@ -251,7 +272,7 @@ export const followSetup = async (
|
|
|
251
272
|
});
|
|
252
273
|
let previous = client.state;
|
|
253
274
|
if (previous === undefined) throw new Error("the setup has no state");
|
|
254
|
-
let pending = diffEvents({ ...previous, log: [] }, previous);
|
|
275
|
+
let pending = diffEvents({ ...previous, log: [] }, previous, guide);
|
|
255
276
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
256
277
|
const flush = () => {
|
|
257
278
|
clearTimeout(timer);
|
|
@@ -263,14 +284,20 @@ export const followSetup = async (
|
|
|
263
284
|
for (const action of gate?.update(state) ?? []) {
|
|
264
285
|
if (action.type === "open") (browser!.open ?? openUrl)(action.url);
|
|
265
286
|
pending.push(
|
|
266
|
-
gateEvent(
|
|
287
|
+
gateEvent(
|
|
288
|
+
action,
|
|
289
|
+
browser!.answerCommand,
|
|
290
|
+
page(state) ?? "",
|
|
291
|
+
waitCommandFor(browser!.answerCommand),
|
|
292
|
+
guide,
|
|
293
|
+
),
|
|
267
294
|
);
|
|
268
295
|
}
|
|
269
296
|
};
|
|
270
297
|
const unsubscribe = client.subscribe(() => {
|
|
271
298
|
const next = client.state;
|
|
272
299
|
if (next === undefined) return;
|
|
273
|
-
pending.push(...diffEvents(previous!, next));
|
|
300
|
+
pending.push(...diffEvents(previous!, next, guide));
|
|
274
301
|
previous = next;
|
|
275
302
|
runGate(next);
|
|
276
303
|
if (pending.length > 0 && timer === undefined)
|