setup-agent 0.1.2 → 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 +11 -3
- package/dist/cli.d.ts +0 -20
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +40 -88
- package/dist/cli.js.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.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 +3 -1
- package/dist/needs.d.ts.map +1 -1
- package/dist/needs.js +20 -17
- package/dist/needs.js.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 +6 -2
- package/dist/stream.d.ts.map +1 -1
- package/dist/stream.js +23 -16
- package/dist/stream.js.map +1 -1
- package/package.json +1 -1
- package/src/cli.test.ts +23 -150
- package/src/cli.ts +51 -135
- 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 -14
- package/src/sdk.ts +21 -31
- package/src/stream.test.ts +8 -5
- package/src/stream.ts +32 -26
- 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,20 +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("never end your turn to wait for it");
|
|
71
|
-
expect(out).not.toContain("Read its new output as it arrives");
|
|
72
|
-
expect(out).toContain('"input.needed"');
|
|
73
|
-
expect(out).toContain('"browser.opened"');
|
|
74
|
-
expect(out).not.toContain("start a persistent Monitor");
|
|
75
|
-
expect(out).toContain("opens the user's browser by itself");
|
|
76
|
-
expect(out).not.toContain("npx setup-agent");
|
|
77
|
-
expect(out).not.toContain("click Begin plan");
|
|
78
|
-
expect(out).toContain(session.browserUrl);
|
|
79
73
|
expect(open).not.toHaveBeenCalled();
|
|
80
74
|
expect(calls.map(([name]) => name)).toEqual([
|
|
81
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. Its new output does not wake you, so never end your turn to wait for it. Read the new lines between your other commands; each line after this briefing is a JSON array of events. Answers reach you through the blocking "wait" commands below.
|
|
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";
|
|
@@ -92,6 +93,7 @@ describe("followSetup", () => {
|
|
|
92
93
|
const lines: StreamEvent[][] = [];
|
|
93
94
|
const open = vi.fn();
|
|
94
95
|
const stream = await followSetup(fake.client, {
|
|
96
|
+
guide: testGuide,
|
|
95
97
|
print: (events) => lines.push(events),
|
|
96
98
|
agent: { cwd: "/app", kind: "claude" },
|
|
97
99
|
browser: {
|
|
@@ -130,7 +132,7 @@ describe("followSetup", () => {
|
|
|
130
132
|
event: "browser.opened",
|
|
131
133
|
id: "q1",
|
|
132
134
|
url: PAGE,
|
|
133
|
-
message: `
|
|
135
|
+
message: `[stream.opened.input] Which package manager? ${PAGE} with "${WAIT} q1"`,
|
|
134
136
|
},
|
|
135
137
|
]);
|
|
136
138
|
|
|
@@ -160,14 +162,14 @@ describe("followSetup", () => {
|
|
|
160
162
|
event: "input.needed",
|
|
161
163
|
id: "q3",
|
|
162
164
|
reason: "recently-opened",
|
|
163
|
-
message: `
|
|
165
|
+
message: `[stream.ask.recently-opened] 10 with "${WAIT} q3" Which registry? Options: pnpm (pnpm), npm (npm).`,
|
|
164
166
|
question: "Which registry?",
|
|
165
167
|
options: [
|
|
166
168
|
{ id: "pnpm", label: "pnpm" },
|
|
167
169
|
{ id: "npm", label: "npm" },
|
|
168
170
|
],
|
|
169
|
-
answerFormat: "
|
|
170
|
-
answer: `${ANSWER} q3 <
|
|
171
|
+
answerFormat: "[need.format.choice]",
|
|
172
|
+
answer: `${ANSWER} q3 <[need.format.choice]>`,
|
|
171
173
|
});
|
|
172
174
|
|
|
173
175
|
// The agent asked in its own conversation and answers for the user.
|
|
@@ -195,7 +197,7 @@ describe("followSetup", () => {
|
|
|
195
197
|
id: "q1",
|
|
196
198
|
reason: "browser-closed",
|
|
197
199
|
message:
|
|
198
|
-
"
|
|
200
|
+
"[stream.ask.browser-closed] Which package manager? Options: pnpm (pnpm), npm (npm).",
|
|
199
201
|
});
|
|
200
202
|
// A question right after: inside the 30s window, and the tab is known to be gone.
|
|
201
203
|
t.ask("Which registry?");
|
|
@@ -211,6 +213,7 @@ describe("followSetup", () => {
|
|
|
211
213
|
const fake = fakeClient();
|
|
212
214
|
const lines: StreamEvent[][] = [];
|
|
213
215
|
const stream = await followSetup(fake.client, {
|
|
216
|
+
guide: testGuide,
|
|
214
217
|
print: (events) => lines.push(events),
|
|
215
218
|
agent: { cwd: "/app" },
|
|
216
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,20 +167,11 @@ export const shellWord = (value: string) =>
|
|
|
165
167
|
? value
|
|
166
168
|
: `'${value.replaceAll("'", `'\\''`)}'`;
|
|
167
169
|
|
|
168
|
-
const
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
) => string
|
|
174
|
-
> = {
|
|
175
|
-
"recently-opened": (question, { agoSeconds, wait }) =>
|
|
176
|
-
`A browser tab for setup was opened ${agoSeconds}s ago. Wait for the user there${wait}, or ask the user directly in the terminal: ${question}`,
|
|
177
|
-
"browser-closed": (question) =>
|
|
178
|
-
`The user closed the setup tab. Ask the user directly in the terminal: ${question}`,
|
|
179
|
-
"no-browser": (question, { page }) =>
|
|
180
|
-
`No browser tab is open for this setup. Ask the user directly in the terminal, or give them ${page}: ${question}`,
|
|
181
|
-
};
|
|
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>;
|
|
182
175
|
|
|
183
176
|
/** The command that blocks on a need, beside the one that answers it: `<cmd> <url> wait`. */
|
|
184
177
|
const waitCommandFor = (answerCommand: string) =>
|
|
@@ -191,7 +184,8 @@ const gateEvent = (
|
|
|
191
184
|
action: BrowserGateAction,
|
|
192
185
|
answerCommand: string,
|
|
193
186
|
page: string,
|
|
194
|
-
waitCommand
|
|
187
|
+
waitCommand: string | undefined,
|
|
188
|
+
guide: Pick<Guide, "texts">,
|
|
195
189
|
): StreamEvent => {
|
|
196
190
|
const { need } = action;
|
|
197
191
|
// Closing the setup is the user's call after "done"; there is nothing to block on.
|
|
@@ -204,14 +198,23 @@ const gateEvent = (
|
|
|
204
198
|
event: "browser.opened",
|
|
205
199
|
id: need.id,
|
|
206
200
|
url: action.url,
|
|
207
|
-
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
|
+
}),
|
|
208
210
|
};
|
|
209
|
-
const question = terminalQuestion(need);
|
|
211
|
+
const question = terminalQuestion(need, guide);
|
|
210
212
|
return {
|
|
211
213
|
event: "input.needed",
|
|
212
214
|
id: need.id,
|
|
213
215
|
reason: action.reason,
|
|
214
|
-
message:
|
|
216
|
+
message: renderText(guide, ASK_TEXTS[action.reason], {
|
|
217
|
+
question: formatTerminalQuestion(question),
|
|
215
218
|
agoSeconds: Math.round((action.openedAgoMs ?? 0) / 1000),
|
|
216
219
|
page,
|
|
217
220
|
wait,
|
|
@@ -222,6 +225,8 @@ const gateEvent = (
|
|
|
222
225
|
};
|
|
223
226
|
|
|
224
227
|
export type FollowSetupOptions = {
|
|
228
|
+
/** The setup server's wording for the stream's messages. */
|
|
229
|
+
guide: Pick<Guide, "texts">;
|
|
225
230
|
/** Writes one line of events; the default prints a JSON array to stdout. */
|
|
226
231
|
print?: (events: StreamEvent[]) => void;
|
|
227
232
|
/** The agent the stream reports in its hello; detected by default. */
|
|
@@ -247,7 +252,7 @@ export type FollowSetupOptions = {
|
|
|
247
252
|
*/
|
|
248
253
|
export const followSetup = async (
|
|
249
254
|
client: Pick<CheckoutClient, "state" | "subscribe" | "commands">,
|
|
250
|
-
{ print, agent, browser }: FollowSetupOptions
|
|
255
|
+
{ print, agent, browser, guide }: FollowSetupOptions,
|
|
251
256
|
) => {
|
|
252
257
|
const write =
|
|
253
258
|
print ??
|
|
@@ -267,7 +272,7 @@ export const followSetup = async (
|
|
|
267
272
|
});
|
|
268
273
|
let previous = client.state;
|
|
269
274
|
if (previous === undefined) throw new Error("the setup has no state");
|
|
270
|
-
let pending = diffEvents({ ...previous, log: [] }, previous);
|
|
275
|
+
let pending = diffEvents({ ...previous, log: [] }, previous, guide);
|
|
271
276
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
272
277
|
const flush = () => {
|
|
273
278
|
clearTimeout(timer);
|
|
@@ -284,6 +289,7 @@ export const followSetup = async (
|
|
|
284
289
|
browser!.answerCommand,
|
|
285
290
|
page(state) ?? "",
|
|
286
291
|
waitCommandFor(browser!.answerCommand),
|
|
292
|
+
guide,
|
|
287
293
|
),
|
|
288
294
|
);
|
|
289
295
|
}
|
|
@@ -291,7 +297,7 @@ export const followSetup = async (
|
|
|
291
297
|
const unsubscribe = client.subscribe(() => {
|
|
292
298
|
const next = client.state;
|
|
293
299
|
if (next === undefined) return;
|
|
294
|
-
pending.push(...diffEvents(previous!, next));
|
|
300
|
+
pending.push(...diffEvents(previous!, next, guide));
|
|
295
301
|
previous = next;
|
|
296
302
|
runGate(next);
|
|
297
303
|
if (pending.length > 0 && timer === undefined)
|