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/dist/stream.js
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import { detectAgentKind } from "./agents.js";
|
|
2
2
|
import { AGENT_HEARTBEAT_MS, classifyChoiceAnswer, parseMultipleAnswer } from "./protocol.js";
|
|
3
|
+
import { renderText } from "./guide.js";
|
|
3
4
|
import { formatTerminalQuestion, terminalQuestion } from "./needs.js";
|
|
4
5
|
import { createBrowserGate } from "./browser.js";
|
|
5
6
|
import spawn from "cross-spawn";
|
|
6
7
|
// src/stream.ts
|
|
7
|
-
|
|
8
|
-
const diffEvents = (before, after) => {
|
|
8
|
+
/** The events between two states, with hints worded by the setup server's `guide`. */
|
|
9
|
+
const diffEvents = (before, after, guide) => {
|
|
9
10
|
const events = [];
|
|
11
|
+
const stayConnected = renderText(guide, "stream.stay-connected");
|
|
10
12
|
if (before.status === "waiting" && after.status === "planning") events.push({ event: "planning.started" });
|
|
11
13
|
for (const entry of after.log) if (entry.role === "user" && entry.acknowledgedAt === void 0 && !before.log.some((previous) => previous.id === entry.id)) events.push({
|
|
12
14
|
event: "message.created",
|
|
@@ -43,11 +45,11 @@ const diffEvents = (before, after) => {
|
|
|
43
45
|
}
|
|
44
46
|
if (before.status !== "done" && after.status === "done") events.push({
|
|
45
47
|
event: "finished",
|
|
46
|
-
next:
|
|
48
|
+
next: stayConnected
|
|
47
49
|
});
|
|
48
50
|
if (before.status !== "cancelled" && after.status === "cancelled") events.push({
|
|
49
51
|
event: "cancelled",
|
|
50
|
-
next:
|
|
52
|
+
next: stayConnected
|
|
51
53
|
});
|
|
52
54
|
if (after.id !== null && after.id !== before.id) events.push({
|
|
53
55
|
event: "setup.created",
|
|
@@ -79,28 +81,37 @@ const openUrl = (url) => {
|
|
|
79
81
|
};
|
|
80
82
|
/** `value` as one shell word, quoted only when it holds more than plain URL characters. */
|
|
81
83
|
const shellWord = (value) => /^[A-Za-z0-9._~%/:@=+,-]+$/.test(value) ? value : `'${value.replaceAll("'", `'\\''`)}'`;
|
|
82
|
-
const
|
|
83
|
-
"recently-opened":
|
|
84
|
-
"browser-closed":
|
|
85
|
-
"no-browser":
|
|
84
|
+
const ASK_TEXTS = {
|
|
85
|
+
"recently-opened": "stream.ask.recently-opened",
|
|
86
|
+
"browser-closed": "stream.ask.browser-closed",
|
|
87
|
+
"no-browser": "stream.ask.no-browser"
|
|
86
88
|
};
|
|
89
|
+
/** The command that blocks on a need, beside the one that answers it: `<cmd> <url> wait`. */
|
|
90
|
+
const waitCommandFor = (answerCommand) => answerCommand.endsWith(" answer") ? `${answerCommand.slice(0, -7)} wait` : void 0;
|
|
87
91
|
/** What the agent reads when setup opened the browser, or when it should ask the user itself. */
|
|
88
|
-
const gateEvent = (action, answerCommand, page) => {
|
|
92
|
+
const gateEvent = (action, answerCommand, page, waitCommand, guide) => {
|
|
89
93
|
const { need } = action;
|
|
94
|
+
const wait = waitCommand !== void 0 && need.kind !== "finish" ? ` with "${waitCommand} ${need.id}"` : "";
|
|
90
95
|
if (action.type === "open") return {
|
|
91
96
|
event: "browser.opened",
|
|
92
97
|
id: need.id,
|
|
93
98
|
url: action.url,
|
|
94
|
-
message: `
|
|
99
|
+
message: renderText(guide, `stream.opened.${need.kind}`, {
|
|
100
|
+
url: action.url,
|
|
101
|
+
wait: wait || " with \"wait\"",
|
|
102
|
+
prompt: need.kind === "input" ? need.input.prompt.trim() || renderText(guide, "need.input.empty-prompt") : ""
|
|
103
|
+
})
|
|
95
104
|
};
|
|
96
|
-
const question = terminalQuestion(need);
|
|
105
|
+
const question = terminalQuestion(need, guide);
|
|
97
106
|
return {
|
|
98
107
|
event: "input.needed",
|
|
99
108
|
id: need.id,
|
|
100
109
|
reason: action.reason,
|
|
101
|
-
message:
|
|
110
|
+
message: renderText(guide, ASK_TEXTS[action.reason], {
|
|
111
|
+
question: formatTerminalQuestion(question),
|
|
102
112
|
agoSeconds: Math.round((action.openedAgoMs ?? 0) / 1e3),
|
|
103
|
-
page
|
|
113
|
+
page,
|
|
114
|
+
wait
|
|
104
115
|
}),
|
|
105
116
|
...question,
|
|
106
117
|
answer: `${answerCommand} ${need.id} <${question.answerFormat}>`
|
|
@@ -112,7 +123,7 @@ const gateEvent = (action, answerCommand, page) => {
|
|
|
112
123
|
* only when the setup waits on the user. Resolves with `stop`, which says
|
|
113
124
|
* goodbye; the caller disposes the client.
|
|
114
125
|
*/
|
|
115
|
-
const followSetup = async (client, { print, agent, browser
|
|
126
|
+
const followSetup = async (client, { print, agent, browser, guide }) => {
|
|
116
127
|
const write = print ?? ((events) => {
|
|
117
128
|
process.stdout.write(`${JSON.stringify(events)}\n`);
|
|
118
129
|
});
|
|
@@ -127,7 +138,7 @@ const followSetup = async (client, { print, agent, browser } = {}) => {
|
|
|
127
138
|
let pending = diffEvents({
|
|
128
139
|
...previous,
|
|
129
140
|
log: []
|
|
130
|
-
}, previous);
|
|
141
|
+
}, previous, guide);
|
|
131
142
|
let timer;
|
|
132
143
|
const flush = () => {
|
|
133
144
|
clearTimeout(timer);
|
|
@@ -138,13 +149,13 @@ const followSetup = async (client, { print, agent, browser } = {}) => {
|
|
|
138
149
|
const runGate = (state) => {
|
|
139
150
|
for (const action of gate?.update(state) ?? []) {
|
|
140
151
|
if (action.type === "open") (browser.open ?? openUrl)(action.url);
|
|
141
|
-
pending.push(gateEvent(action, browser.answerCommand, page(state) ?? ""));
|
|
152
|
+
pending.push(gateEvent(action, browser.answerCommand, page(state) ?? "", waitCommandFor(browser.answerCommand), guide));
|
|
142
153
|
}
|
|
143
154
|
};
|
|
144
155
|
const unsubscribe = client.subscribe(() => {
|
|
145
156
|
const next = client.state;
|
|
146
157
|
if (next === void 0) return;
|
|
147
|
-
pending.push(...diffEvents(previous, next));
|
|
158
|
+
pending.push(...diffEvents(previous, next, guide));
|
|
148
159
|
previous = next;
|
|
149
160
|
runGate(next);
|
|
150
161
|
if (pending.length > 0 && timer === void 0) timer = setTimeout(flush, 200);
|
package/dist/stream.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"stream.js","names":[],"sources":["../src/stream.ts"],"sourcesContent":["import spawn from \"cross-spawn\";\nimport { detectAgentKind } from \"./agents\";\nimport { createBrowserGate } from \"./browser\";\nimport type { AskReason, BrowserGateAction } from \"./browser\";\nimport type { CheckoutClient } from \"./client\";\nimport { formatTerminalQuestion, terminalQuestion } from \"./needs\";\nimport type { TerminalQuestion } from \"./needs\";\nimport {\n AGENT_HEARTBEAT_MS,\n classifyChoiceAnswer,\n parseMultipleAnswer,\n} from \"./protocol\";\nimport type { Checkout } from \"./protocol\";\n\nexport type StreamEvent =\n | { event: \"planning.started\" }\n | { event: \"message.created\"; messageId: string; text: string }\n | {\n event: \"input.answered\";\n inputId: string;\n prompt: string;\n /** The entries of a multiple choice answer, else the answer text. */\n answer: string | string[];\n note?: string;\n /** True when a choice answer is, or a multiple one contains, the user's own text rather than an option. */\n custom?: true;\n }\n | { event: \"input.dismissed\"; inputId: string; prompt: string }\n | { event: \"plan.approved\"; revision: number }\n | { event: \"plan.changes-requested\"; revision: number; feedback: string }\n | { event: \"finished\"; next: string }\n | { event: \"cancelled\"; next: string }\n | { event: \"setup.created\"; products: string[] }\n | {\n event: \"browser.opened\";\n /** The need the tab opened for: an input id, plan or finish. */\n id: string;\n url: string;\n message: string;\n }\n | ({\n event: \"input.needed\";\n id: string;\n reason: AskReason;\n message: string;\n /** The command that answers it, with the answer's shape in angle brackets. */\n answer: string;\n } & TerminalQuestion);\n\nconst STAY_CONNECTED =\n \"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.\";\n\nexport const diffEvents = (\n before: Checkout.State,\n after: Checkout.State,\n): StreamEvent[] => {\n const events: StreamEvent[] = [];\n if (before.status === \"waiting\" && after.status === \"planning\") {\n events.push({ event: \"planning.started\" });\n }\n for (const entry of after.log) {\n if (\n entry.role === \"user\" &&\n entry.acknowledgedAt === undefined &&\n !before.log.some((previous) => previous.id === entry.id)\n ) {\n events.push({\n event: \"message.created\",\n messageId: entry.id,\n text: entry.text,\n });\n }\n }\n for (const input of after.inputs) {\n const previous = before.inputs.find(\n (candidate) => candidate.id === input.id,\n );\n if (previous?.status === input.status) continue;\n if (input.status === \"answered\") {\n const answer = input.answer ?? \"\";\n events.push({\n event: \"input.answered\",\n inputId: input.id,\n prompt: input.prompt,\n ...answerFields(input, answer),\n });\n } else if (input.status === \"dismissed\" && after.status !== \"cancelled\") {\n events.push({\n event: \"input.dismissed\",\n inputId: input.id,\n prompt: input.prompt,\n });\n }\n }\n for (const plan of after.plans) {\n const previous = before.plans.find(\n (candidate) => candidate.revision === plan.revision,\n );\n if (previous?.status === plan.status || plan.status === \"proposed\") {\n continue;\n }\n if (plan.status === \"approved\") {\n events.push({ event: \"plan.approved\", revision: plan.revision });\n } else {\n events.push({\n event: \"plan.changes-requested\",\n revision: plan.revision,\n feedback: plan.feedback ?? \"\",\n });\n }\n }\n if (before.status !== \"done\" && after.status === \"done\") {\n events.push({ event: \"finished\", next: STAY_CONNECTED });\n }\n if (before.status !== \"cancelled\" && after.status === \"cancelled\") {\n events.push({ event: \"cancelled\", next: STAY_CONNECTED });\n }\n if (after.id !== null && after.id !== before.id) {\n events.push({\n event: \"setup.created\",\n products: after.products.map((product) => product.slug),\n });\n }\n return events;\n};\n\nexport const answerFields = (input: Checkout.Input, answer: string) => ({\n answer: (input.multiple && parseMultipleAnswer(answer)) || answer,\n ...(input.note !== undefined && { note: input.note }),\n ...(input.kind === \"choice\" &&\n classifyChoiceAnswer(input, answer) === \"custom\" && {\n custom: true as const,\n }),\n});\n\n/** The setup page that joins a link; the link rides in the fragment, which never reaches a server. */\nexport const setupPageUrl = (\n setupAppUrl: string,\n linkId: string,\n setupId: string,\n) => `${setupAppUrl.replace(/\\/$/, \"\")}/#link=${linkId}&setup=${setupId}`;\n\n/**\n * Opens `url` in the user's default browser without waiting for it.\n * `SETUP_AGENT_BROWSER` names another program to run with the URL instead,\n * such as a test driver.\n */\nconst openUrl = (url: string) => {\n const custom = process.env.SETUP_AGENT_BROWSER?.trim();\n const [command, args] = custom\n ? [custom, [url]]\n : process.platform === \"darwin\"\n ? [\"open\", [url]]\n : process.platform === \"win32\"\n ? [\"rundll32\", [\"url.dll,FileProtocolHandler\", url]]\n : [\"xdg-open\", [url]];\n const child = spawn(command, args, { detached: true, stdio: \"ignore\" });\n child.on(\"error\", () => {});\n child.unref();\n};\n\n/** `value` as one shell word, quoted only when it holds more than plain URL characters. */\nexport const shellWord = (value: string) =>\n /^[A-Za-z0-9._~%/:@=+,-]+$/.test(value)\n ? value\n : `'${value.replaceAll(\"'\", `'\\\\''`)}'`;\n\nconst ASK_MESSAGES: Record<\n AskReason,\n (question: string, context: { agoSeconds: number; page: string }) => string\n> = {\n \"recently-opened\": (question, { agoSeconds }) =>\n `A browser tab for setup was opened ${agoSeconds}s ago. Wait for the user there, or ask the user directly in the terminal: ${question}`,\n \"browser-closed\": (question) =>\n `The user closed the setup tab. Ask the user directly in the terminal: ${question}`,\n \"no-browser\": (question, { page }) =>\n `No browser tab is open for this setup. Ask the user directly in the terminal, or give them ${page}: ${question}`,\n};\n\n/** What the agent reads when setup opened the browser, or when it should ask the user itself. */\nconst gateEvent = (\n action: BrowserGateAction,\n answerCommand: string,\n page: string,\n): StreamEvent => {\n const { need } = action;\n if (action.type === \"open\")\n return {\n event: \"browser.opened\",\n id: need.id,\n url: action.url,\n message: `Opened the setup in the user's browser for ${need.kind === \"input\" ? `\"${need.input.prompt.trim() || \"Your agent needs an answer.\"}\"` : need.kind === \"plan\" ? \"the plan review\" : \"closing the setup\"}. Tell the user once that it is waiting there, and give them ${action.url} in case it did not open. The answer arrives on this stream.`,\n };\n const question = terminalQuestion(need);\n return {\n event: \"input.needed\",\n id: need.id,\n reason: action.reason,\n message: ASK_MESSAGES[action.reason](formatTerminalQuestion(question), {\n agoSeconds: Math.round((action.openedAgoMs ?? 0) / 1000),\n page,\n }),\n ...question,\n answer: `${answerCommand} ${need.id} <${question.answerFormat}>`,\n };\n};\n\nexport type FollowSetupOptions = {\n /** Writes one line of events; the default prints a JSON array to stdout. */\n print?: (events: StreamEvent[]) => void;\n /** The agent the stream reports in its hello; detected by default. */\n agent?: { cwd?: string; kind?: string };\n /** Opens the setup page when the setup waits on the user; without it, the stream never opens one. */\n browser?: {\n setupAppUrl: string;\n linkId: string;\n /** The command that answers a need, before the need id: `npx assistant-ui@latest setup <url> answer`. */\n answerCommand: string;\n open?: (url: string) => void;\n /** False for `--no-open`: the agent is told to ask instead. */\n canOpen?: boolean;\n now?: () => number;\n };\n};\n\n/**\n * Follows a setup as the agent: says hello, keeps the agent present with\n * heartbeats, prints what changed as stream events, and opens the browser\n * only when the setup waits on the user. Resolves with `stop`, which says\n * goodbye; the caller disposes the client.\n */\nexport const followSetup = async (\n client: Pick<CheckoutClient, \"state\" | \"subscribe\" | \"commands\">,\n { print, agent, browser }: FollowSetupOptions = {},\n) => {\n const write =\n print ??\n ((events: StreamEvent[]) => {\n process.stdout.write(`${JSON.stringify(events)}\\n`);\n });\n const page = (state: Checkout.State) =>\n browser && state.id !== null\n ? setupPageUrl(browser.setupAppUrl, browser.linkId, state.id)\n : undefined;\n const gate =\n browser &&\n createBrowserGate({\n url: page,\n ...(browser.canOpen !== undefined && { canOpen: browser.canOpen }),\n ...(browser.now !== undefined && { now: browser.now }),\n });\n let previous = client.state;\n if (previous === undefined) throw new Error(\"the setup has no state\");\n let pending = diffEvents({ ...previous, log: [] }, previous);\n let timer: ReturnType<typeof setTimeout> | undefined;\n const flush = () => {\n clearTimeout(timer);\n timer = undefined;\n if (pending.length > 0) write(pending);\n pending = [];\n };\n const runGate = (state: Checkout.State) => {\n for (const action of gate?.update(state) ?? []) {\n if (action.type === \"open\") (browser!.open ?? openUrl)(action.url);\n pending.push(\n gateEvent(action, browser!.answerCommand, page(state) ?? \"\"),\n );\n }\n };\n const unsubscribe = client.subscribe(() => {\n const next = client.state;\n if (next === undefined) return;\n pending.push(...diffEvents(previous!, next));\n previous = next;\n runGate(next);\n if (pending.length > 0 && timer === undefined)\n timer = setTimeout(flush, 200);\n });\n runGate(previous);\n flush();\n // Presence lapses without a state change, so the gate also looks every second.\n const tick =\n gate &&\n setInterval(() => {\n runGate(previous!);\n flush();\n }, 1_000);\n const kind = agent?.kind ?? detectAgentKind();\n try {\n await client.commands[\"agent/hello\"]({\n cwd: agent?.cwd ?? process.cwd(),\n ...(kind !== undefined && { kind }),\n });\n } catch (error) {\n clearInterval(tick);\n clearTimeout(timer);\n unsubscribe();\n throw error;\n }\n const heartbeat = setInterval(() => {\n void client.commands[\"agent/heartbeat\"]().catch(() => {});\n }, AGENT_HEARTBEAT_MS);\n let stopped: Promise<void> | undefined;\n return {\n stop: () =>\n (stopped ??= (async () => {\n clearInterval(heartbeat);\n clearInterval(tick);\n unsubscribe();\n flush();\n await client.commands[\"agent/bye\"]().catch(() => {});\n })()),\n };\n};\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"stream.js","names":[],"sources":["../src/stream.ts"],"sourcesContent":["import spawn from \"cross-spawn\";\nimport { detectAgentKind } from \"./agents\";\nimport { createBrowserGate } from \"./browser\";\nimport type { AskReason, BrowserGateAction } from \"./browser\";\nimport type { CheckoutClient } from \"./client\";\nimport { formatTerminalQuestion, terminalQuestion } from \"./needs\";\nimport type { TerminalQuestion } from \"./needs\";\nimport {\n AGENT_HEARTBEAT_MS,\n classifyChoiceAnswer,\n parseMultipleAnswer,\n} from \"./protocol\";\nimport type { Checkout } from \"./protocol\";\nimport { renderText } from \"./guide\";\nimport type { Guide } from \"./guide\";\n\nexport type StreamEvent =\n | { event: \"planning.started\" }\n | { event: \"message.created\"; messageId: string; text: string }\n | {\n event: \"input.answered\";\n inputId: string;\n prompt: string;\n /** The entries of a multiple choice answer, else the answer text. */\n answer: string | string[];\n note?: string;\n /** True when a choice answer is, or a multiple one contains, the user's own text rather than an option. */\n custom?: true;\n }\n | { event: \"input.dismissed\"; inputId: string; prompt: string }\n | { event: \"plan.approved\"; revision: number }\n | { event: \"plan.changes-requested\"; revision: number; feedback: string }\n | { event: \"finished\"; next: string }\n | { event: \"cancelled\"; next: string }\n | { event: \"setup.created\"; products: string[] }\n | {\n event: \"browser.opened\";\n /** The need the tab opened for: an input id, plan or finish. */\n id: string;\n url: string;\n message: string;\n }\n | ({\n event: \"input.needed\";\n id: string;\n reason: AskReason;\n message: string;\n /** The command that answers it, with the answer's shape in angle brackets. */\n answer: string;\n } & TerminalQuestion);\n\n/** The events between two states, with hints worded by the setup server's `guide`. */\nexport const diffEvents = (\n before: Checkout.State,\n after: Checkout.State,\n guide: Pick<Guide, \"texts\">,\n): StreamEvent[] => {\n const events: StreamEvent[] = [];\n const stayConnected = renderText(guide, \"stream.stay-connected\");\n if (before.status === \"waiting\" && after.status === \"planning\") {\n events.push({ event: \"planning.started\" });\n }\n for (const entry of after.log) {\n if (\n entry.role === \"user\" &&\n entry.acknowledgedAt === undefined &&\n !before.log.some((previous) => previous.id === entry.id)\n ) {\n events.push({\n event: \"message.created\",\n messageId: entry.id,\n text: entry.text,\n });\n }\n }\n for (const input of after.inputs) {\n const previous = before.inputs.find(\n (candidate) => candidate.id === input.id,\n );\n if (previous?.status === input.status) continue;\n if (input.status === \"answered\") {\n const answer = input.answer ?? \"\";\n events.push({\n event: \"input.answered\",\n inputId: input.id,\n prompt: input.prompt,\n ...answerFields(input, answer),\n });\n } else if (input.status === \"dismissed\" && after.status !== \"cancelled\") {\n events.push({\n event: \"input.dismissed\",\n inputId: input.id,\n prompt: input.prompt,\n });\n }\n }\n for (const plan of after.plans) {\n const previous = before.plans.find(\n (candidate) => candidate.revision === plan.revision,\n );\n if (previous?.status === plan.status || plan.status === \"proposed\") {\n continue;\n }\n if (plan.status === \"approved\") {\n events.push({ event: \"plan.approved\", revision: plan.revision });\n } else {\n events.push({\n event: \"plan.changes-requested\",\n revision: plan.revision,\n feedback: plan.feedback ?? \"\",\n });\n }\n }\n if (before.status !== \"done\" && after.status === \"done\") {\n events.push({ event: \"finished\", next: stayConnected });\n }\n if (before.status !== \"cancelled\" && after.status === \"cancelled\") {\n events.push({ event: \"cancelled\", next: stayConnected });\n }\n if (after.id !== null && after.id !== before.id) {\n events.push({\n event: \"setup.created\",\n products: after.products.map((product) => product.slug),\n });\n }\n return events;\n};\n\nexport const answerFields = (input: Checkout.Input, answer: string) => ({\n answer: (input.multiple && parseMultipleAnswer(answer)) || answer,\n ...(input.note !== undefined && { note: input.note }),\n ...(input.kind === \"choice\" &&\n classifyChoiceAnswer(input, answer) === \"custom\" && {\n custom: true as const,\n }),\n});\n\n/** The setup page that joins a link; the link rides in the fragment, which never reaches a server. */\nexport const setupPageUrl = (\n setupAppUrl: string,\n linkId: string,\n setupId: string,\n) => `${setupAppUrl.replace(/\\/$/, \"\")}/#link=${linkId}&setup=${setupId}`;\n\n/**\n * Opens `url` in the user's default browser without waiting for it.\n * `SETUP_AGENT_BROWSER` names another program to run with the URL instead,\n * such as a test driver.\n */\nconst openUrl = (url: string) => {\n const custom = process.env.SETUP_AGENT_BROWSER?.trim();\n const [command, args] = custom\n ? [custom, [url]]\n : process.platform === \"darwin\"\n ? [\"open\", [url]]\n : process.platform === \"win32\"\n ? [\"rundll32\", [\"url.dll,FileProtocolHandler\", url]]\n : [\"xdg-open\", [url]];\n const child = spawn(command, args, { detached: true, stdio: \"ignore\" });\n child.on(\"error\", () => {});\n child.unref();\n};\n\n/** `value` as one shell word, quoted only when it holds more than plain URL characters. */\nexport const shellWord = (value: string) =>\n /^[A-Za-z0-9._~%/:@=+,-]+$/.test(value)\n ? value\n : `'${value.replaceAll(\"'\", `'\\\\''`)}'`;\n\nconst ASK_TEXTS = {\n \"recently-opened\": \"stream.ask.recently-opened\",\n \"browser-closed\": \"stream.ask.browser-closed\",\n \"no-browser\": \"stream.ask.no-browser\",\n} as const satisfies Record<AskReason, string>;\n\n/** The command that blocks on a need, beside the one that answers it: `<cmd> <url> wait`. */\nconst waitCommandFor = (answerCommand: string) =>\n answerCommand.endsWith(\" answer\")\n ? `${answerCommand.slice(0, -\" answer\".length)} wait`\n : undefined;\n\n/** What the agent reads when setup opened the browser, or when it should ask the user itself. */\nconst gateEvent = (\n action: BrowserGateAction,\n answerCommand: string,\n page: string,\n waitCommand: string | undefined,\n guide: Pick<Guide, \"texts\">,\n): StreamEvent => {\n const { need } = action;\n // Closing the setup is the user's call after \"done\"; there is nothing to block on.\n const wait =\n waitCommand !== undefined && need.kind !== \"finish\"\n ? ` with \"${waitCommand} ${need.id}\"`\n : \"\";\n if (action.type === \"open\")\n return {\n event: \"browser.opened\",\n id: need.id,\n url: action.url,\n message: renderText(guide, `stream.opened.${need.kind}`, {\n url: action.url,\n wait: wait || ' with \"wait\"',\n prompt:\n need.kind === \"input\"\n ? need.input.prompt.trim() ||\n renderText(guide, \"need.input.empty-prompt\")\n : \"\",\n }),\n };\n const question = terminalQuestion(need, guide);\n return {\n event: \"input.needed\",\n id: need.id,\n reason: action.reason,\n message: renderText(guide, ASK_TEXTS[action.reason], {\n question: formatTerminalQuestion(question),\n agoSeconds: Math.round((action.openedAgoMs ?? 0) / 1000),\n page,\n wait,\n }),\n ...question,\n answer: `${answerCommand} ${need.id} <${question.answerFormat}>`,\n };\n};\n\nexport type FollowSetupOptions = {\n /** The setup server's wording for the stream's messages. */\n guide: Pick<Guide, \"texts\">;\n /** Writes one line of events; the default prints a JSON array to stdout. */\n print?: (events: StreamEvent[]) => void;\n /** The agent the stream reports in its hello; detected by default. */\n agent?: { cwd?: string; kind?: string };\n /** Opens the setup page when the setup waits on the user; without it, the stream never opens one. */\n browser?: {\n setupAppUrl: string;\n linkId: string;\n /** The command that answers a need, before the need id: `npx assistant-ui@latest setup <url> answer`. */\n answerCommand: string;\n open?: (url: string) => void;\n /** False for `--no-open`: the agent is told to ask instead. */\n canOpen?: boolean;\n now?: () => number;\n };\n};\n\n/**\n * Follows a setup as the agent: says hello, keeps the agent present with\n * heartbeats, prints what changed as stream events, and opens the browser\n * only when the setup waits on the user. Resolves with `stop`, which says\n * goodbye; the caller disposes the client.\n */\nexport const followSetup = async (\n client: Pick<CheckoutClient, \"state\" | \"subscribe\" | \"commands\">,\n { print, agent, browser, guide }: FollowSetupOptions,\n) => {\n const write =\n print ??\n ((events: StreamEvent[]) => {\n process.stdout.write(`${JSON.stringify(events)}\\n`);\n });\n const page = (state: Checkout.State) =>\n browser && state.id !== null\n ? setupPageUrl(browser.setupAppUrl, browser.linkId, state.id)\n : undefined;\n const gate =\n browser &&\n createBrowserGate({\n url: page,\n ...(browser.canOpen !== undefined && { canOpen: browser.canOpen }),\n ...(browser.now !== undefined && { now: browser.now }),\n });\n let previous = client.state;\n if (previous === undefined) throw new Error(\"the setup has no state\");\n let pending = diffEvents({ ...previous, log: [] }, previous, guide);\n let timer: ReturnType<typeof setTimeout> | undefined;\n const flush = () => {\n clearTimeout(timer);\n timer = undefined;\n if (pending.length > 0) write(pending);\n pending = [];\n };\n const runGate = (state: Checkout.State) => {\n for (const action of gate?.update(state) ?? []) {\n if (action.type === \"open\") (browser!.open ?? openUrl)(action.url);\n pending.push(\n gateEvent(\n action,\n browser!.answerCommand,\n page(state) ?? \"\",\n waitCommandFor(browser!.answerCommand),\n guide,\n ),\n );\n }\n };\n const unsubscribe = client.subscribe(() => {\n const next = client.state;\n if (next === undefined) return;\n pending.push(...diffEvents(previous!, next, guide));\n previous = next;\n runGate(next);\n if (pending.length > 0 && timer === undefined)\n timer = setTimeout(flush, 200);\n });\n runGate(previous);\n flush();\n // Presence lapses without a state change, so the gate also looks every second.\n const tick =\n gate &&\n setInterval(() => {\n runGate(previous!);\n flush();\n }, 1_000);\n const kind = agent?.kind ?? detectAgentKind();\n try {\n await client.commands[\"agent/hello\"]({\n cwd: agent?.cwd ?? process.cwd(),\n ...(kind !== undefined && { kind }),\n });\n } catch (error) {\n clearInterval(tick);\n clearTimeout(timer);\n unsubscribe();\n throw error;\n }\n const heartbeat = setInterval(() => {\n void client.commands[\"agent/heartbeat\"]().catch(() => {});\n }, AGENT_HEARTBEAT_MS);\n let stopped: Promise<void> | undefined;\n return {\n stop: () =>\n (stopped ??= (async () => {\n clearInterval(heartbeat);\n clearInterval(tick);\n unsubscribe();\n flush();\n await client.commands[\"agent/bye\"]().catch(() => {});\n })()),\n };\n};\n"],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "setup-agent",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "A live setup shared between a browser and the coding agent installing the products in it, on Statewire",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"homepage": "https://www.assistant-ui.com/",
|
|
@@ -51,7 +51,7 @@
|
|
|
51
51
|
"statewire": "^0.22.1"
|
|
52
52
|
},
|
|
53
53
|
"peerDependencies": {
|
|
54
|
-
"@assistant-ui/tap": "^0.9.
|
|
54
|
+
"@assistant-ui/tap": "^0.9.26"
|
|
55
55
|
},
|
|
56
56
|
"peerDependenciesMeta": {
|
|
57
57
|
"@assistant-ui/tap": {
|
|
@@ -59,8 +59,8 @@
|
|
|
59
59
|
}
|
|
60
60
|
},
|
|
61
61
|
"devDependencies": {
|
|
62
|
-
"@assistant-ui/tap": "0.9.
|
|
63
|
-
"@assistant-ui/x-buildutils": "0.0.
|
|
62
|
+
"@assistant-ui/tap": "0.9.26",
|
|
63
|
+
"@assistant-ui/x-buildutils": "0.0.33",
|
|
64
64
|
"@types/cross-spawn": "^6.0.6",
|
|
65
65
|
"@types/node": "^26.6.5",
|
|
66
66
|
"@types/react": "^19.3.0",
|
package/src/cli.test.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import type { connectCheckout } from "./client";
|
|
3
3
|
import { initialCheckoutState, type Checkout } from "./protocol";
|
|
4
|
+
import { testGuide } from "./tests/guide-fixture";
|
|
4
5
|
import { describe, expect, it, onTestFinished, vi } from "vitest";
|
|
5
6
|
import {
|
|
6
7
|
chmod,
|
|
@@ -19,10 +20,8 @@ import { tmpdir } from "node:os";
|
|
|
19
20
|
import { join } from "node:path";
|
|
20
21
|
import {
|
|
21
22
|
HELP,
|
|
22
|
-
INSTRUCTIONS,
|
|
23
23
|
main,
|
|
24
24
|
askSeed,
|
|
25
|
-
agentInstructions,
|
|
26
25
|
diffEvents,
|
|
27
26
|
detectAgentKind,
|
|
28
27
|
isDirectInvocation,
|
|
@@ -41,21 +40,115 @@ vi.mock("./client", async (importOriginal) => ({
|
|
|
41
40
|
connectCheckout: connectCheckoutMock,
|
|
42
41
|
}));
|
|
43
42
|
|
|
44
|
-
describe("the
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
);
|
|
51
|
-
|
|
52
|
-
|
|
43
|
+
describe("waiting on the user", () => {
|
|
44
|
+
// Under the two minutes Claude Code's shell gives a command by default.
|
|
45
|
+
const DEFAULT_WAIT_SECONDS = 100;
|
|
46
|
+
const URL = "https://checkout.test/session";
|
|
47
|
+
|
|
48
|
+
const connect = () => {
|
|
49
|
+
const listeners = new Set<() => void>();
|
|
50
|
+
const state: Checkout.State = {
|
|
51
|
+
...initialCheckoutState(),
|
|
52
|
+
id: "setup-a",
|
|
53
|
+
createdAt: 1,
|
|
54
|
+
status: "planning",
|
|
55
|
+
inputs: [
|
|
56
|
+
{
|
|
57
|
+
id: "q1",
|
|
58
|
+
phase: "planning",
|
|
59
|
+
kind: "text",
|
|
60
|
+
prompt: "Which project?",
|
|
61
|
+
optional: false,
|
|
62
|
+
status: "open",
|
|
63
|
+
createdAt: 2,
|
|
64
|
+
},
|
|
65
|
+
],
|
|
66
|
+
};
|
|
67
|
+
const client = {
|
|
68
|
+
state,
|
|
69
|
+
subscribe: (listener: () => void) => {
|
|
70
|
+
listeners.add(listener);
|
|
71
|
+
return () => listeners.delete(listener);
|
|
72
|
+
},
|
|
73
|
+
commands: {},
|
|
74
|
+
dispose: vi.fn(),
|
|
75
|
+
};
|
|
76
|
+
connectCheckoutMock.mockResolvedValue(
|
|
77
|
+
client as unknown as Awaited<ReturnType<typeof connectCheckout>>,
|
|
53
78
|
);
|
|
79
|
+
const stdout = vi.spyOn(process.stdout, "write").mockReturnValue(true);
|
|
80
|
+
vi.useFakeTimers();
|
|
81
|
+
onTestFinished(() => {
|
|
82
|
+
vi.useRealTimers();
|
|
83
|
+
stdout.mockRestore();
|
|
84
|
+
connectCheckoutMock.mockReset();
|
|
85
|
+
});
|
|
86
|
+
const printed = () =>
|
|
87
|
+
JSON.parse(String(stdout.mock.lastCall?.[0] ?? "null")) as unknown;
|
|
88
|
+
const answer = (value: string) => {
|
|
89
|
+
state.inputs[0]!.status = "answered";
|
|
90
|
+
state.inputs[0]!.answer = value;
|
|
91
|
+
for (const listener of listeners) listener();
|
|
92
|
+
};
|
|
93
|
+
return { state, printed, answer };
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
it("hands the turn back with the command to run again before an agent shell kills the wait", async () => {
|
|
97
|
+
const t = connect();
|
|
98
|
+
const run = main([URL, "wait", "q1"]);
|
|
99
|
+
await vi.advanceTimersByTimeAsync(DEFAULT_WAIT_SECONDS * 1000 - 1);
|
|
100
|
+
expect(t.printed()).toBeNull();
|
|
101
|
+
await vi.advanceTimersByTimeAsync(1);
|
|
102
|
+
await run;
|
|
103
|
+
expect(t.printed()).toMatchObject({
|
|
104
|
+
ok: false,
|
|
105
|
+
status: "waiting",
|
|
106
|
+
waitingFor: "q1",
|
|
107
|
+
next: `npx setup-agent ${URL} wait q1`,
|
|
108
|
+
});
|
|
54
109
|
});
|
|
55
110
|
|
|
56
|
-
it("
|
|
57
|
-
const
|
|
58
|
-
|
|
111
|
+
it("hands back a plan wait as wait plan, so the agent does not submit the plan again", async () => {
|
|
112
|
+
const t = connect();
|
|
113
|
+
t.state.plans.push({
|
|
114
|
+
revision: 1,
|
|
115
|
+
markdown: "## Steps",
|
|
116
|
+
status: "proposed",
|
|
117
|
+
submittedAt: 3,
|
|
118
|
+
});
|
|
119
|
+
const run = main([URL, "wait", "plan"]);
|
|
120
|
+
await vi.advanceTimersByTimeAsync(DEFAULT_WAIT_SECONDS * 1000);
|
|
121
|
+
await run;
|
|
122
|
+
expect(t.printed()).toMatchObject({
|
|
123
|
+
ok: false,
|
|
124
|
+
status: "waiting",
|
|
125
|
+
waitingFor: "plan",
|
|
126
|
+
next: `npx setup-agent ${URL} wait plan`,
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("prints the answer when it arrives in time", async () => {
|
|
131
|
+
const t = connect();
|
|
132
|
+
const run = main([URL, "wait", "q1"]);
|
|
133
|
+
await vi.advanceTimersByTimeAsync(5_000);
|
|
134
|
+
t.answer("apps/web");
|
|
135
|
+
await run;
|
|
136
|
+
expect(t.printed()).toMatchObject({
|
|
137
|
+
ok: true,
|
|
138
|
+
inputId: "q1",
|
|
139
|
+
status: "answered",
|
|
140
|
+
answer: "apps/web",
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("waits as long as it takes with --timeout 0", async () => {
|
|
145
|
+
const t = connect();
|
|
146
|
+
const run = main([URL, "wait", "q1", "--timeout", "0"]);
|
|
147
|
+
await vi.advanceTimersByTimeAsync(DEFAULT_WAIT_SECONDS * 1000 * 5);
|
|
148
|
+
expect(t.printed()).toBeNull();
|
|
149
|
+
t.answer("apps/web");
|
|
150
|
+
await run;
|
|
151
|
+
expect(t.printed()).toMatchObject({ ok: true, answer: "apps/web" });
|
|
59
152
|
});
|
|
60
153
|
});
|
|
61
154
|
|
|
@@ -437,10 +530,10 @@ describe("chat stream events", () => {
|
|
|
437
530
|
},
|
|
438
531
|
],
|
|
439
532
|
};
|
|
440
|
-
expect(diffEvents(before, after)).toEqual([
|
|
533
|
+
expect(diffEvents(before, after, testGuide)).toEqual([
|
|
441
534
|
{ event: "message.created", messageId: "l2", text: "Use pnpm" },
|
|
442
535
|
]);
|
|
443
|
-
expect(diffEvents(after, after)).toEqual([]);
|
|
536
|
+
expect(diffEvents(after, after, testGuide)).toEqual([]);
|
|
444
537
|
});
|
|
445
538
|
});
|
|
446
539
|
|
|
@@ -465,7 +558,9 @@ describe("begin planning", () => {
|
|
|
465
558
|
...before,
|
|
466
559
|
inputs: [{ ...asked, status: "answered", answer, answeredAt: 2 }],
|
|
467
560
|
});
|
|
468
|
-
expect(
|
|
561
|
+
expect(
|
|
562
|
+
diffEvents(before, answered('["slack","email"]'), testGuide),
|
|
563
|
+
).toEqual([
|
|
469
564
|
{
|
|
470
565
|
event: "input.answered",
|
|
471
566
|
inputId: "q1",
|
|
@@ -473,7 +568,9 @@ describe("begin planning", () => {
|
|
|
473
568
|
answer: ["slack", "email"],
|
|
474
569
|
},
|
|
475
570
|
]);
|
|
476
|
-
expect(
|
|
571
|
+
expect(
|
|
572
|
+
diffEvents(before, answered('["email","Discord"]'), testGuide),
|
|
573
|
+
).toEqual([
|
|
477
574
|
{
|
|
478
575
|
event: "input.answered",
|
|
479
576
|
inputId: "q1",
|
|
@@ -490,18 +587,18 @@ describe("begin planning", () => {
|
|
|
490
587
|
...waiting,
|
|
491
588
|
agent: { ...waiting.agent, connected: true, lastSeenAt: 1 },
|
|
492
589
|
};
|
|
493
|
-
expect(diffEvents(waiting, connected)).toEqual([]);
|
|
590
|
+
expect(diffEvents(waiting, connected, testGuide)).toEqual([]);
|
|
494
591
|
const planning: Checkout.State = { ...connected, status: "planning" };
|
|
495
|
-
expect(diffEvents(connected, planning)).toEqual([
|
|
592
|
+
expect(diffEvents(connected, planning, testGuide)).toEqual([
|
|
496
593
|
{ event: "planning.started" },
|
|
497
594
|
]);
|
|
498
|
-
expect(diffEvents(planning, planning)).toEqual([]);
|
|
499
|
-
expect(
|
|
500
|
-
{
|
|
501
|
-
]);
|
|
502
|
-
expect(
|
|
503
|
-
{
|
|
504
|
-
]);
|
|
595
|
+
expect(diffEvents(planning, planning, testGuide)).toEqual([]);
|
|
596
|
+
expect(
|
|
597
|
+
diffEvents(waiting, { ...waiting, status: "done" }, testGuide),
|
|
598
|
+
).toEqual([{ event: "finished", next: "[stream.stay-connected]" }]);
|
|
599
|
+
expect(
|
|
600
|
+
diffEvents(waiting, { ...waiting, status: "cancelled" }, testGuide),
|
|
601
|
+
).toEqual([{ event: "cancelled", next: "[stream.stay-connected]" }]);
|
|
505
602
|
});
|
|
506
603
|
|
|
507
604
|
it("announces the next setup the browser creates on the same link", () => {
|
|
@@ -516,13 +613,13 @@ describe("begin planning", () => {
|
|
|
516
613
|
id: "c2",
|
|
517
614
|
products: [{ slug: "cloud", name: "Cloud" }],
|
|
518
615
|
};
|
|
519
|
-
expect(diffEvents(done, next)).toEqual([
|
|
616
|
+
expect(diffEvents(done, next, testGuide)).toEqual([
|
|
520
617
|
{ event: "setup.created", products: ["cloud"] },
|
|
521
618
|
]);
|
|
522
|
-
expect(diffEvents(next, next)).toEqual([]);
|
|
523
|
-
expect(
|
|
524
|
-
|
|
525
|
-
);
|
|
619
|
+
expect(diffEvents(next, next, testGuide)).toEqual([]);
|
|
620
|
+
expect(
|
|
621
|
+
diffEvents(initialCheckoutState(), initialCheckoutState(), testGuide),
|
|
622
|
+
).toEqual([]);
|
|
526
623
|
});
|
|
527
624
|
|
|
528
625
|
it.each(["planning", "cancelled"] as const)(
|
|
@@ -601,90 +698,6 @@ describe("begin planning", () => {
|
|
|
601
698
|
);
|
|
602
699
|
});
|
|
603
700
|
|
|
604
|
-
describe("agent instructions", () => {
|
|
605
|
-
it("goes straight to planning for a setup the agent started", () => {
|
|
606
|
-
const browserUrl =
|
|
607
|
-
"https://setup.assistant-ui.com/#link=abc12345&setup=def67890";
|
|
608
|
-
const started = agentInstructions("https://setup.test/abc12345", {
|
|
609
|
-
startedByAgent: { browserUrl },
|
|
610
|
-
});
|
|
611
|
-
expect(started).not.toContain("click Begin plan");
|
|
612
|
-
expect(started).not.toContain("wait start\nKeep the stream running");
|
|
613
|
-
expect(started).toContain("there is no Begin plan click to wait for");
|
|
614
|
-
expect(started).toContain(browserUrl);
|
|
615
|
-
expect(started).toContain("a setup you opened in the user's browser");
|
|
616
|
-
const opened = agentInstructions("https://setup.test/abc12345");
|
|
617
|
-
expect(opened).toContain("click Begin plan");
|
|
618
|
-
expect(opened).toContain("a setup the user opened in their browser");
|
|
619
|
-
});
|
|
620
|
-
|
|
621
|
-
it("asks what to set up when the setup has no products", () => {
|
|
622
|
-
expect(agentInstructions("https://setup.test/s")).toContain(
|
|
623
|
-
"If the setup lists no products, first ask the user what to set up",
|
|
624
|
-
);
|
|
625
|
-
});
|
|
626
|
-
|
|
627
|
-
it("prints a setup URL as one quoted shell word", () => {
|
|
628
|
-
const instructions = agentInstructions("https://checkout.test/a'b;c");
|
|
629
|
-
expect(instructions).toContain(
|
|
630
|
-
"npx setup-agent 'https://checkout.test/a'\\''b;c' stream",
|
|
631
|
-
);
|
|
632
|
-
expect(instructions).not.toContain(
|
|
633
|
-
"npx setup-agent https://checkout.test/a'",
|
|
634
|
-
);
|
|
635
|
-
expect(agentInstructions("https://checkout.test/s")).toContain(
|
|
636
|
-
"npx setup-agent https://checkout.test/s stream",
|
|
637
|
-
);
|
|
638
|
-
});
|
|
639
|
-
|
|
640
|
-
it("discovers related products and asks contextual entry points before the plan", () => {
|
|
641
|
-
const instructions = agentInstructions(
|
|
642
|
-
"http://localhost:18892/checkout/session",
|
|
643
|
-
);
|
|
644
|
-
expect(instructions).toContain(
|
|
645
|
-
"products are starting names and guide references",
|
|
646
|
-
);
|
|
647
|
-
expect(instructions).toContain(
|
|
648
|
-
"The starting list does not restrict the implementation",
|
|
649
|
-
);
|
|
650
|
-
expect(instructions).toContain("only 1–3 sensible alternatives");
|
|
651
|
-
expect(instructions).toContain(
|
|
652
|
-
"two options may use the same form factor in different places",
|
|
653
|
-
);
|
|
654
|
-
expect(instructions).toContain("--entry-points '<JSON array>'");
|
|
655
|
-
expect(instructions).not.toContain("--product assistant-ui");
|
|
656
|
-
expect(instructions.indexOf("--entry-points")).toBeLessThan(
|
|
657
|
-
instructions.indexOf("plan --file"),
|
|
658
|
-
);
|
|
659
|
-
});
|
|
660
|
-
|
|
661
|
-
it("waits for the entry-point answer before the plan is submitted", () => {
|
|
662
|
-
const ask = agentInstructions("http://localhost:18892/checkout/session")
|
|
663
|
-
.split("\n")
|
|
664
|
-
.find((line) => line.includes("--entry-points '<JSON array>'"));
|
|
665
|
-
expect(ask?.trim().endsWith("--wait")).toBe(true);
|
|
666
|
-
expect(ask).not.toContain("--default");
|
|
667
|
-
});
|
|
668
|
-
|
|
669
|
-
it("skips the entry-point question for a setup that puts no assistant in the app", () => {
|
|
670
|
-
const instructions = agentInstructions(
|
|
671
|
-
"http://localhost:18892/checkout/session",
|
|
672
|
-
);
|
|
673
|
-
expect(instructions).toContain(
|
|
674
|
-
"When the setup puts an assistant in the app, ask how users should access it",
|
|
675
|
-
);
|
|
676
|
-
expect(instructions).toContain(
|
|
677
|
-
"Skip this question when no product's guide installs an assistant",
|
|
678
|
-
);
|
|
679
|
-
expect(instructions).toContain(
|
|
680
|
-
"When you ask it, do not submit the plan before the user picks an entry point.",
|
|
681
|
-
);
|
|
682
|
-
expect(instructions).not.toContain(
|
|
683
|
-
"recommendation. Do not submit the plan before the user picks an entry point.",
|
|
684
|
-
);
|
|
685
|
-
});
|
|
686
|
-
});
|
|
687
|
-
|
|
688
701
|
describe("writeEnvSecret", () => {
|
|
689
702
|
const tempDir = async () => {
|
|
690
703
|
const dir = await mkdtemp(join(tmpdir(), "setup-agent-"));
|