setup-agent 0.0.8 → 0.1.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 +31 -0
- package/dist/agents.d.ts +33 -0
- package/dist/agents.d.ts.map +1 -0
- package/dist/agents.js +221 -0
- package/dist/agents.js.map +1 -0
- package/dist/browser.d.ts +40 -0
- package/dist/browser.d.ts.map +1 -0
- package/dist/browser.js +91 -0
- package/dist/browser.js.map +1 -0
- package/dist/cli.d.ts +38 -49
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +89 -180
- package/dist/cli.js.map +1 -1
- package/dist/host.d.ts.map +1 -1
- package/dist/host.js +13 -0
- package/dist/host.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/needs.d.ts +62 -0
- package/dist/needs.d.ts.map +1 -0
- package/dist/needs.js +184 -0
- package/dist/needs.js.map +1 -0
- package/dist/protocol.d.ts +16 -0
- package/dist/protocol.d.ts.map +1 -1
- package/dist/protocol.js +8 -1
- package/dist/protocol.js.map +1 -1
- package/dist/sdk.d.ts +48 -0
- package/dist/sdk.d.ts.map +1 -0
- package/dist/sdk.js +97 -0
- package/dist/sdk.js.map +1 -0
- package/dist/stream.d.ts +93 -0
- package/dist/stream.d.ts.map +1 -0
- package/dist/stream.js +184 -0
- package/dist/stream.js.map +1 -0
- package/package.json +16 -2
- package/src/agents.test.ts +137 -0
- package/src/agents.ts +243 -0
- package/src/browser.test.ts +192 -0
- package/src/browser.ts +146 -0
- package/src/cli.test.ts +78 -0
- package/src/cli.ts +189 -258
- package/src/host.test.ts +25 -0
- package/src/host.ts +7 -0
- package/src/index.ts +1 -0
- package/src/needs.test.ts +180 -0
- package/src/needs.ts +273 -0
- package/src/protocol.ts +17 -0
- package/src/sdk.test.ts +111 -0
- package/src/sdk.ts +145 -0
- package/src/stream.test.ts +223 -0
- package/src/stream.ts +314 -0
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
answerNeed,
|
|
4
|
+
formatTerminalQuestion,
|
|
5
|
+
pendingNeeds,
|
|
6
|
+
terminalQuestion,
|
|
7
|
+
} from "./needs";
|
|
8
|
+
import { presetInput } from "./presets";
|
|
9
|
+
import { initialCheckoutState } from "./protocol";
|
|
10
|
+
import type { Checkout } from "./protocol";
|
|
11
|
+
|
|
12
|
+
const stateWith = (...inputs: Partial<Checkout.Input>[]): Checkout.State => ({
|
|
13
|
+
...initialCheckoutState(),
|
|
14
|
+
id: "s1",
|
|
15
|
+
createdAt: 1,
|
|
16
|
+
status: "planning",
|
|
17
|
+
plans: [{ revision: 3, markdown: "x", status: "proposed", submittedAt: 1 }],
|
|
18
|
+
inputs: inputs.map((input, index) => ({
|
|
19
|
+
id: `q${index + 1}`,
|
|
20
|
+
phase: "planning",
|
|
21
|
+
kind: "text",
|
|
22
|
+
prompt: "Where?",
|
|
23
|
+
optional: false,
|
|
24
|
+
status: "open",
|
|
25
|
+
createdAt: index + 1,
|
|
26
|
+
...input,
|
|
27
|
+
})),
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
/** Records the commands an answer sends. */
|
|
31
|
+
const clientFor = (state: Checkout.State) => {
|
|
32
|
+
const calls: [string, unknown][] = [];
|
|
33
|
+
const commands = new Proxy(
|
|
34
|
+
{},
|
|
35
|
+
{
|
|
36
|
+
get: (_, name: string) => async (params: unknown) =>
|
|
37
|
+
void calls.push([name, params]),
|
|
38
|
+
},
|
|
39
|
+
) as Parameters<typeof answerNeed>[0]["commands"];
|
|
40
|
+
return { client: { state, commands }, calls };
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
describe("pendingNeeds", () => {
|
|
44
|
+
it("lists open inputs, a proposed plan and a proposal to close, until a follow-up or the end", () => {
|
|
45
|
+
const state = stateWith({}, { status: "answered" });
|
|
46
|
+
state.completion = { proposedAt: 9 };
|
|
47
|
+
expect(pendingNeeds(state).map((need) => need.id)).toEqual([
|
|
48
|
+
"q1",
|
|
49
|
+
"plan",
|
|
50
|
+
"finish",
|
|
51
|
+
]);
|
|
52
|
+
state.log.push({
|
|
53
|
+
id: "l1",
|
|
54
|
+
role: "user",
|
|
55
|
+
phase: "installing",
|
|
56
|
+
at: 10,
|
|
57
|
+
text: "more",
|
|
58
|
+
acknowledgedAt: 11,
|
|
59
|
+
});
|
|
60
|
+
expect(pendingNeeds(state).map((need) => need.id)).toEqual(["q1", "plan"]);
|
|
61
|
+
state.status = "done";
|
|
62
|
+
expect(pendingNeeds(state)).toEqual([]);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe("terminalQuestion", () => {
|
|
67
|
+
it("names variants as the ids the answer takes, and never asks for an API key", () => {
|
|
68
|
+
const [framework, model] = pendingNeeds(
|
|
69
|
+
stateWith(
|
|
70
|
+
presetInput("framework") as Checkout.Input,
|
|
71
|
+
presetInput("llm-provider") as Checkout.Input,
|
|
72
|
+
),
|
|
73
|
+
).map(terminalQuestion);
|
|
74
|
+
expect(framework!.options?.map((option) => option.id)).toEqual([
|
|
75
|
+
"ai-sdk:typescript",
|
|
76
|
+
"mastra:typescript",
|
|
77
|
+
"langgraph:python",
|
|
78
|
+
"langgraph:typescript",
|
|
79
|
+
]);
|
|
80
|
+
expect(formatTerminalQuestion(framework!)).toMatch(
|
|
81
|
+
/^Which agent framework runs your backend\? Options: ai-sdk:typescript \(Vercel AI SDK \(TypeScript\): Shortest path/,
|
|
82
|
+
);
|
|
83
|
+
expect(model!.question).toContain("The API key is not typed here");
|
|
84
|
+
expect(model!.answerFormat).toBe(
|
|
85
|
+
"<provider>:<model>, such as openai:gpt-5",
|
|
86
|
+
);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe("answerNeed", () => {
|
|
91
|
+
it("sends each kind of answer in the shape the setup takes", async () => {
|
|
92
|
+
const { client, calls } = clientFor(
|
|
93
|
+
stateWith(
|
|
94
|
+
{},
|
|
95
|
+
{
|
|
96
|
+
kind: "choice",
|
|
97
|
+
multiple: true,
|
|
98
|
+
options: [
|
|
99
|
+
{ id: "slack", label: "Slack" },
|
|
100
|
+
{ id: "email", label: "Email" },
|
|
101
|
+
],
|
|
102
|
+
},
|
|
103
|
+
presetInput("llm-provider") as Checkout.Input,
|
|
104
|
+
{ kind: "product", product: "cloud" },
|
|
105
|
+
{ optional: true },
|
|
106
|
+
),
|
|
107
|
+
);
|
|
108
|
+
await answerNeed(client, "q1", "apps/web", { note: " from chat " });
|
|
109
|
+
await answerNeed(client, "q2", "slack, email");
|
|
110
|
+
await answerNeed(
|
|
111
|
+
client,
|
|
112
|
+
"q3",
|
|
113
|
+
'{"provider":"anthropic","model":"claude-sonnet-5","apiKey":"sk-x"}',
|
|
114
|
+
);
|
|
115
|
+
await answerNeed(client, "q4", "add");
|
|
116
|
+
await answerNeed(client, "q5", undefined, { dismiss: true });
|
|
117
|
+
await answerNeed(client, "plan", "revise", { feedback: "use pnpm" });
|
|
118
|
+
await answerNeed(client, "plan", "approve");
|
|
119
|
+
expect(calls).toEqual([
|
|
120
|
+
[
|
|
121
|
+
"checkout/answer",
|
|
122
|
+
{ inputId: "q1", answer: "apps/web", note: "from chat" },
|
|
123
|
+
],
|
|
124
|
+
["checkout/answer", { inputId: "q2", answer: '["slack","email"]' }],
|
|
125
|
+
[
|
|
126
|
+
"checkout/answer",
|
|
127
|
+
{
|
|
128
|
+
inputId: "q3",
|
|
129
|
+
answer: JSON.stringify({
|
|
130
|
+
provider: "anthropic",
|
|
131
|
+
model: "claude-sonnet-5",
|
|
132
|
+
}),
|
|
133
|
+
},
|
|
134
|
+
],
|
|
135
|
+
[
|
|
136
|
+
"checkout/add-product",
|
|
137
|
+
{ inputId: "q4", product: { slug: "cloud", name: "cloud" } },
|
|
138
|
+
],
|
|
139
|
+
["checkout/dismiss", { inputId: "q5" }],
|
|
140
|
+
[
|
|
141
|
+
"checkout/plan",
|
|
142
|
+
{ id: "s1", revision: 3, decision: "revise", feedback: "use pnpm" },
|
|
143
|
+
],
|
|
144
|
+
["checkout/plan", { id: "s1", revision: 3, decision: "approve" }],
|
|
145
|
+
]);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("refuses an answer the setup would before sending it, and closes only a current proposal", async () => {
|
|
149
|
+
const { client, calls } = clientFor(
|
|
150
|
+
stateWith(
|
|
151
|
+
presetInput("framework") as Checkout.Input,
|
|
152
|
+
presetInput("llm-provider") as Checkout.Input,
|
|
153
|
+
{
|
|
154
|
+
status: "answered",
|
|
155
|
+
},
|
|
156
|
+
),
|
|
157
|
+
);
|
|
158
|
+
await expect(answerNeed(client, "q1", "langgraph")).rejects.toThrow(
|
|
159
|
+
"langgraph:python",
|
|
160
|
+
);
|
|
161
|
+
await expect(answerNeed(client, "q2", "nope:x")).rejects.toThrow(
|
|
162
|
+
"providers",
|
|
163
|
+
);
|
|
164
|
+
await expect(answerNeed(client, "q3", "x")).rejects.toThrow(
|
|
165
|
+
"already answered",
|
|
166
|
+
);
|
|
167
|
+
await expect(answerNeed(client, "plan", "revise")).rejects.toThrow(
|
|
168
|
+
"--feedback",
|
|
169
|
+
);
|
|
170
|
+
await expect(answerNeed(client, "finish", "close")).rejects.toThrow(
|
|
171
|
+
"no proposal to close",
|
|
172
|
+
);
|
|
173
|
+
client.state.completion = { proposedAt: 9 };
|
|
174
|
+
await answerNeed(client, "finish", "close");
|
|
175
|
+
expect(calls).toEqual([["checkout/finish", undefined]]);
|
|
176
|
+
await expect(
|
|
177
|
+
answerNeed(client, "q1", undefined, { dismiss: true }),
|
|
178
|
+
).rejects.toThrow("required");
|
|
179
|
+
});
|
|
180
|
+
});
|
package/src/needs.ts
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import type { CheckoutClient } from "./client";
|
|
2
|
+
import {
|
|
3
|
+
classifyChoiceAnswer,
|
|
4
|
+
currentPlan,
|
|
5
|
+
finishProposed,
|
|
6
|
+
isClosed,
|
|
7
|
+
isValidEntryPointAnswer,
|
|
8
|
+
isValidModelAnswer,
|
|
9
|
+
parseModelAnswer,
|
|
10
|
+
openInputs,
|
|
11
|
+
parseMultipleAnswer,
|
|
12
|
+
} from "./protocol";
|
|
13
|
+
import type { Checkout } from "./protocol";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Something the setup waits on the user for: an open question, a proposed
|
|
17
|
+
* plan, or the agent's proposal to close. `id` is what `answer` takes: the
|
|
18
|
+
* input id, `plan` or `finish`.
|
|
19
|
+
*/
|
|
20
|
+
export type SetupNeed =
|
|
21
|
+
| { id: string; kind: "input"; input: Checkout.Input }
|
|
22
|
+
| { id: "plan"; kind: "plan"; plan: Checkout.Plan }
|
|
23
|
+
| { id: "finish"; kind: "finish"; completion: Checkout.Completion };
|
|
24
|
+
|
|
25
|
+
/** What the setup waits on the user for right now, oldest first. */
|
|
26
|
+
export const pendingNeeds = (state: Checkout.State): SetupNeed[] => {
|
|
27
|
+
if (state.createdAt === null || isClosed(state)) return [];
|
|
28
|
+
const needs: SetupNeed[] = openInputs(state).map((input) => ({
|
|
29
|
+
id: input.id,
|
|
30
|
+
kind: "input",
|
|
31
|
+
input,
|
|
32
|
+
}));
|
|
33
|
+
const plan = currentPlan(state);
|
|
34
|
+
if (plan?.status === "proposed")
|
|
35
|
+
needs.push({ id: "plan", kind: "plan", plan });
|
|
36
|
+
// Any message after the proposal, acknowledged or not, means more work first.
|
|
37
|
+
if (
|
|
38
|
+
finishProposed(state) &&
|
|
39
|
+
!state.log.some(
|
|
40
|
+
(entry) =>
|
|
41
|
+
entry.role === "user" && entry.at > state.completion!.proposedAt,
|
|
42
|
+
)
|
|
43
|
+
)
|
|
44
|
+
needs.push({ id: "finish", kind: "finish", completion: state.completion! });
|
|
45
|
+
return needs;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/** Tells one need from a later one with the same id, such as a new plan revision. */
|
|
49
|
+
export const needKey = (need: SetupNeed) =>
|
|
50
|
+
need.kind === "input"
|
|
51
|
+
? `input:${need.input.id}:${need.input.createdAt}`
|
|
52
|
+
: need.kind === "plan"
|
|
53
|
+
? `plan:${need.plan.revision}:${need.plan.submittedAt}`
|
|
54
|
+
: `finish:${need.completion.proposedAt}`;
|
|
55
|
+
|
|
56
|
+
type NeedOption = { id: string; label: string; description?: string };
|
|
57
|
+
|
|
58
|
+
/** A need as a question the agent can put to the user in its own conversation. */
|
|
59
|
+
export type TerminalQuestion = {
|
|
60
|
+
question: string;
|
|
61
|
+
options?: NeedOption[];
|
|
62
|
+
multiple?: true;
|
|
63
|
+
/** The shape of the answer, as the `answer` command takes it. */
|
|
64
|
+
answerFormat: string;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/** Options as the ids an answer names: `<option>:<variant>` for an option with variants. */
|
|
68
|
+
const optionList = (input: Checkout.Input): NeedOption[] =>
|
|
69
|
+
(input.options ?? []).flatMap((option) => {
|
|
70
|
+
const where = option.entryPoint
|
|
71
|
+
? ` (${option.entryPoint.formFactor}; ${option.entryPoint.placement}; ${option.entryPoint.trigger})`
|
|
72
|
+
: "";
|
|
73
|
+
const description = `${option.description ?? ""}${where}`.trim();
|
|
74
|
+
const entry = (id: string, label: string) => ({
|
|
75
|
+
id,
|
|
76
|
+
label,
|
|
77
|
+
...(description && { description }),
|
|
78
|
+
});
|
|
79
|
+
return option.variants?.length
|
|
80
|
+
? option.variants.map((variant) =>
|
|
81
|
+
entry(
|
|
82
|
+
`${option.id}:${variant.id}`,
|
|
83
|
+
`${option.label} (${variant.label})`,
|
|
84
|
+
),
|
|
85
|
+
)
|
|
86
|
+
: [entry(option.id, option.label)];
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const ANSWER_FORMATS: Record<Checkout.InputKind, string> = {
|
|
90
|
+
text: "the user's text, as one shell word",
|
|
91
|
+
product: "add or decline",
|
|
92
|
+
model: "<provider>:<model>, such as openai:gpt-5",
|
|
93
|
+
"entry-point": "one option id",
|
|
94
|
+
choice: "one option id, or the user's own text",
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
export const terminalQuestion = (need: SetupNeed): TerminalQuestion => {
|
|
98
|
+
if (need.kind === "plan")
|
|
99
|
+
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.",
|
|
102
|
+
options: [
|
|
103
|
+
{ id: "approve", label: "Approve the plan" },
|
|
104
|
+
{ id: "revise", label: "Ask for changes (pass --feedback)" },
|
|
105
|
+
],
|
|
106
|
+
answerFormat: 'approve, or revise --feedback "<what to change>"',
|
|
107
|
+
};
|
|
108
|
+
if (need.kind === "finish")
|
|
109
|
+
return {
|
|
110
|
+
question: `Everything is installed${need.completion.preview ? ` and running at ${need.completion.preview}` : ""}. Close the setup, or keep going?`,
|
|
111
|
+
options: [{ id: "close", label: "Close the setup" }],
|
|
112
|
+
answerFormat:
|
|
113
|
+
"close; if the user wants more, do it and run done again instead",
|
|
114
|
+
};
|
|
115
|
+
const { input } = need;
|
|
116
|
+
const options =
|
|
117
|
+
input.kind === "product"
|
|
118
|
+
? [
|
|
119
|
+
{ id: "add", label: `Add ${input.product ?? "the product"}` },
|
|
120
|
+
{ id: "decline", label: "Leave it out" },
|
|
121
|
+
]
|
|
122
|
+
: input.kind === "text"
|
|
123
|
+
? undefined
|
|
124
|
+
: optionList(input);
|
|
125
|
+
return {
|
|
126
|
+
question: `${input.prompt.trim() || "Your agent needs an answer."}${input.optional ? " (optional; --dismiss skips it)" : ""}${input.kind === "model" ? " The API key is not typed here: the user adds it to the project's env file themselves." : ""}`,
|
|
127
|
+
...(options && { options }),
|
|
128
|
+
...(input.multiple && { multiple: true as const }),
|
|
129
|
+
answerFormat: input.multiple
|
|
130
|
+
? "option ids separated by commas, or the user's own text"
|
|
131
|
+
: ANSWER_FORMATS[input.kind],
|
|
132
|
+
};
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
/** The question as one line: the prompt, then its options. */
|
|
136
|
+
export const formatTerminalQuestion = ({
|
|
137
|
+
question,
|
|
138
|
+
options,
|
|
139
|
+
multiple,
|
|
140
|
+
}: TerminalQuestion) =>
|
|
141
|
+
options
|
|
142
|
+
? `${question} Options${multiple ? ", any number" : ""}: ${options
|
|
143
|
+
.map(
|
|
144
|
+
(option) =>
|
|
145
|
+
`${option.id} (${option.label}${option.description ? `: ${option.description}` : ""})`,
|
|
146
|
+
)
|
|
147
|
+
.join(", ")}.`
|
|
148
|
+
: question;
|
|
149
|
+
|
|
150
|
+
const ids = (input: Checkout.Input) =>
|
|
151
|
+
optionList(input)
|
|
152
|
+
.map((option) => option.id)
|
|
153
|
+
.join(", ");
|
|
154
|
+
|
|
155
|
+
/** `value` as the wire answer `input` takes, or an error naming the shape it needs. */
|
|
156
|
+
const wireAnswer = (input: Checkout.Input, value: string) => {
|
|
157
|
+
const trimmed = value.trim();
|
|
158
|
+
if (input.kind === "model") {
|
|
159
|
+
const at = trimmed.indexOf(":");
|
|
160
|
+
const json = trimmed.startsWith("{")
|
|
161
|
+
? trimmed
|
|
162
|
+
: JSON.stringify({
|
|
163
|
+
provider: trimmed.slice(0, at),
|
|
164
|
+
model: trimmed.slice(at + 1),
|
|
165
|
+
});
|
|
166
|
+
if (at <= 0 && !trimmed.startsWith("{"))
|
|
167
|
+
throw new Error(
|
|
168
|
+
`a model answer is "<provider>:<model>", such as openai:gpt-5`,
|
|
169
|
+
);
|
|
170
|
+
if (!isValidModelAnswer(input, json))
|
|
171
|
+
throw new Error(
|
|
172
|
+
`a model answer names one of the providers: ${ids(input)}`,
|
|
173
|
+
);
|
|
174
|
+
// Only the documented fields; anything else (a key, say) never reaches the setup.
|
|
175
|
+
return JSON.stringify(parseModelAnswer(json));
|
|
176
|
+
}
|
|
177
|
+
if (input.kind === "entry-point") {
|
|
178
|
+
if (!isValidEntryPointAnswer(input, trimmed))
|
|
179
|
+
throw new Error(`an entry-point answer is one of: ${ids(input)}`);
|
|
180
|
+
return trimmed;
|
|
181
|
+
}
|
|
182
|
+
if (input.kind === "choice") {
|
|
183
|
+
const answer =
|
|
184
|
+
input.multiple && parseMultipleAnswer(trimmed) === undefined
|
|
185
|
+
? JSON.stringify(
|
|
186
|
+
trimmed
|
|
187
|
+
.split(",")
|
|
188
|
+
.map((entry) => entry.trim())
|
|
189
|
+
.filter(Boolean),
|
|
190
|
+
)
|
|
191
|
+
: trimmed;
|
|
192
|
+
if (classifyChoiceAnswer(input, answer) === "invalid")
|
|
193
|
+
throw new Error(
|
|
194
|
+
`"${value}" names an option without a valid variant; pick from: ${ids(input)}`,
|
|
195
|
+
);
|
|
196
|
+
return answer;
|
|
197
|
+
}
|
|
198
|
+
if (trimmed === "") throw new Error("an answer cannot be empty");
|
|
199
|
+
return value;
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Answers a need from the agent's side, with what the user said in the
|
|
204
|
+
* agent's own conversation: an input, the plan (`approve` or `revise` with
|
|
205
|
+
* feedback) or the proposal to close (`close`).
|
|
206
|
+
*/
|
|
207
|
+
export const answerNeed = async (
|
|
208
|
+
client: Pick<CheckoutClient, "state" | "commands">,
|
|
209
|
+
id: string,
|
|
210
|
+
value: string | undefined,
|
|
211
|
+
options: { note?: string; feedback?: string; dismiss?: boolean } = {},
|
|
212
|
+
) => {
|
|
213
|
+
const state = client.state;
|
|
214
|
+
if (state === undefined) throw new Error("the setup has no state");
|
|
215
|
+
const { commands } = client;
|
|
216
|
+
if (id === "plan") {
|
|
217
|
+
const plan = currentPlan(state);
|
|
218
|
+
if (plan === undefined) throw new Error("no plan has been proposed");
|
|
219
|
+
const feedback = options.feedback?.trim();
|
|
220
|
+
if (value !== "approve" && (value !== "revise" || !feedback))
|
|
221
|
+
throw new Error(
|
|
222
|
+
'answer the plan with approve, or revise --feedback "<what to change>"',
|
|
223
|
+
);
|
|
224
|
+
await commands["checkout/plan"]({
|
|
225
|
+
...(state.id !== null && { id: state.id }),
|
|
226
|
+
revision: plan.revision,
|
|
227
|
+
...(value === "approve"
|
|
228
|
+
? { decision: "approve" as const }
|
|
229
|
+
: { decision: "revise" as const, feedback: feedback! }),
|
|
230
|
+
});
|
|
231
|
+
return { ok: true, id, decision: value };
|
|
232
|
+
}
|
|
233
|
+
if (id === "finish") {
|
|
234
|
+
if (!pendingNeeds(state).some((need) => need.id === "finish"))
|
|
235
|
+
throw new Error(
|
|
236
|
+
"no proposal to close is waiting; finish the user's follow-up and run done again",
|
|
237
|
+
);
|
|
238
|
+
if (value !== "close")
|
|
239
|
+
throw new Error(
|
|
240
|
+
"answer finish with close; if the user wants more, do it and run done again",
|
|
241
|
+
);
|
|
242
|
+
await commands["checkout/finish"]();
|
|
243
|
+
return { ok: true, id, status: "done" };
|
|
244
|
+
}
|
|
245
|
+
const input = state.inputs.find((candidate) => candidate.id === id);
|
|
246
|
+
if (input === undefined) throw new Error(`no input "${id}"`);
|
|
247
|
+
if (input.status !== "open")
|
|
248
|
+
throw new Error(`input "${id}" is already ${input.status}`);
|
|
249
|
+
if (options.dismiss && !input.optional)
|
|
250
|
+
throw new Error(`input "${id}" is required; answer it instead`);
|
|
251
|
+
if (options.dismiss || (input.kind === "product" && value === "decline")) {
|
|
252
|
+
await commands["checkout/dismiss"]({ inputId: id });
|
|
253
|
+
return { ok: true, id, status: "dismissed" };
|
|
254
|
+
}
|
|
255
|
+
if (value === undefined) throw new Error(`usage: answer ${id} <answer>`);
|
|
256
|
+
if (input.kind === "product") {
|
|
257
|
+
if (value !== "add")
|
|
258
|
+
throw new Error("answer a product with add or decline");
|
|
259
|
+
const slug = input.product ?? "";
|
|
260
|
+
await commands["checkout/add-product"]({
|
|
261
|
+
inputId: id,
|
|
262
|
+
product: { slug, name: slug },
|
|
263
|
+
});
|
|
264
|
+
} else {
|
|
265
|
+
const note = options.note?.trim();
|
|
266
|
+
await commands["checkout/answer"]({
|
|
267
|
+
inputId: id,
|
|
268
|
+
answer: wireAnswer(input, value),
|
|
269
|
+
...(note && { note }),
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
return { ok: true, id, status: "answered" };
|
|
273
|
+
};
|
package/src/protocol.ts
CHANGED
|
@@ -174,6 +174,15 @@ export namespace Checkout {
|
|
|
174
174
|
/** When the command was first run against this checkout, before the agent joined the stream. */
|
|
175
175
|
introducedAt: number | null;
|
|
176
176
|
};
|
|
177
|
+
/**
|
|
178
|
+
* The setup page in the user's browser, while one has joined the link.
|
|
179
|
+
* Absent on hosts older than browser presence.
|
|
180
|
+
*/
|
|
181
|
+
browser?: {
|
|
182
|
+
lastSeenAt: number | null;
|
|
183
|
+
/** False once the page said goodbye; heartbeats set it again. */
|
|
184
|
+
connected: boolean;
|
|
185
|
+
};
|
|
177
186
|
/** Every revision in order; the last one is current. */
|
|
178
187
|
plans: Plan[];
|
|
179
188
|
steps: Step[];
|
|
@@ -231,6 +240,10 @@ export namespace Checkout {
|
|
|
231
240
|
"agent/hello": (params: { cwd?: string; kind?: string }) => void;
|
|
232
241
|
"agent/heartbeat": () => void;
|
|
233
242
|
"agent/bye": () => void;
|
|
243
|
+
/** The setup page is open and joined; sent every BROWSER_HEARTBEAT_MS. */
|
|
244
|
+
"browser/heartbeat": () => void;
|
|
245
|
+
/** The setup page is closing. */
|
|
246
|
+
"browser/bye": () => void;
|
|
234
247
|
"agent/plan": (params: { markdown: string }) => { revision: number };
|
|
235
248
|
"agent/add-step": (params: {
|
|
236
249
|
title: string;
|
|
@@ -276,6 +289,9 @@ export namespace Checkout {
|
|
|
276
289
|
|
|
277
290
|
export const AGENT_PRESENCE_MS = 15_000;
|
|
278
291
|
export const AGENT_HEARTBEAT_MS = 5_000;
|
|
292
|
+
/** A background tab's timers slow down, so a browser counts as present for longer than an agent. */
|
|
293
|
+
export const BROWSER_PRESENCE_MS = 20_000;
|
|
294
|
+
export const BROWSER_HEARTBEAT_MS = 5_000;
|
|
279
295
|
|
|
280
296
|
export const initialCheckoutState = (): Checkout.State => ({
|
|
281
297
|
version: 2,
|
|
@@ -291,6 +307,7 @@ export const initialCheckoutState = (): Checkout.State => ({
|
|
|
291
307
|
kind: null,
|
|
292
308
|
introducedAt: null,
|
|
293
309
|
},
|
|
310
|
+
browser: { lastSeenAt: null, connected: false },
|
|
294
311
|
plans: [],
|
|
295
312
|
steps: [],
|
|
296
313
|
inputs: [],
|
package/src/sdk.test.ts
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import * as sdk from "./sdk";
|
|
3
|
+
import { initialCheckoutState } from "./protocol";
|
|
4
|
+
import type { Checkout } from "./protocol";
|
|
5
|
+
import type { CheckoutClient } from "./client";
|
|
6
|
+
|
|
7
|
+
describe("setup-agent/sdk", () => {
|
|
8
|
+
it("exports only what a CLI embeds setup with", () => {
|
|
9
|
+
expect(Object.keys(sdk)).toEqual(["runAgentSetup"]);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("runs a whole agent setup: session, briefing, then a stream that opens the page on the first question", async () => {
|
|
13
|
+
vi.useFakeTimers({ now: 5_000_000 });
|
|
14
|
+
try {
|
|
15
|
+
let state: Checkout.State = initialCheckoutState();
|
|
16
|
+
const listeners = new Set<() => void>();
|
|
17
|
+
const calls: [string, unknown][] = [];
|
|
18
|
+
const commands = new Proxy(
|
|
19
|
+
{},
|
|
20
|
+
{
|
|
21
|
+
get: (_, name: string) => async (params: unknown) => {
|
|
22
|
+
calls.push([name, params]);
|
|
23
|
+
if (name === "checkout/create") {
|
|
24
|
+
const { id, products } = params as {
|
|
25
|
+
id: string;
|
|
26
|
+
products: Checkout.Product[];
|
|
27
|
+
};
|
|
28
|
+
state = { ...state, id, products, createdAt: Date.now() };
|
|
29
|
+
}
|
|
30
|
+
if (name === "checkout/begin-plan")
|
|
31
|
+
state = { ...state, status: "planning" };
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
);
|
|
35
|
+
const client = {
|
|
36
|
+
get state() {
|
|
37
|
+
return state;
|
|
38
|
+
},
|
|
39
|
+
subscribe: (listener: () => void) => {
|
|
40
|
+
listeners.add(listener);
|
|
41
|
+
return () => listeners.delete(listener);
|
|
42
|
+
},
|
|
43
|
+
commands,
|
|
44
|
+
dispose: vi.fn(),
|
|
45
|
+
} as unknown as CheckoutClient;
|
|
46
|
+
const connect = vi.fn(async () => client);
|
|
47
|
+
const open = vi.fn();
|
|
48
|
+
let out = "";
|
|
49
|
+
const lines: unknown[] = [];
|
|
50
|
+
const { session, stream } = await sdk.runAgentSetup({
|
|
51
|
+
server: "https://checkout.test",
|
|
52
|
+
setupAppUrl: "https://setup.test",
|
|
53
|
+
products: [{ slug: "assistant-ui", name: "assistant-ui" }],
|
|
54
|
+
command: "npx my-tool setup",
|
|
55
|
+
name: "my-tool setup",
|
|
56
|
+
env: {},
|
|
57
|
+
connect,
|
|
58
|
+
open,
|
|
59
|
+
write: (text) => (out += text),
|
|
60
|
+
stream: {
|
|
61
|
+
print: (events) => lines.push(...events),
|
|
62
|
+
agent: { cwd: "/app" },
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
expect(out).toMatch(/^my-tool setup is running\./);
|
|
66
|
+
expect(out).toContain(
|
|
67
|
+
`npx my-tool setup https://checkout.test/${session.linkId} stream`,
|
|
68
|
+
);
|
|
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
|
+
expect(open).not.toHaveBeenCalled();
|
|
78
|
+
expect(calls.map(([name]) => name)).toEqual([
|
|
79
|
+
"checkout/create",
|
|
80
|
+
"checkout/begin-plan",
|
|
81
|
+
"agent/intro",
|
|
82
|
+
"agent/hello",
|
|
83
|
+
]);
|
|
84
|
+
expect(connect).toHaveBeenCalledTimes(1);
|
|
85
|
+
|
|
86
|
+
state = {
|
|
87
|
+
...state,
|
|
88
|
+
inputs: [
|
|
89
|
+
{
|
|
90
|
+
id: "q1",
|
|
91
|
+
phase: "planning",
|
|
92
|
+
kind: "text",
|
|
93
|
+
prompt: "Which project?",
|
|
94
|
+
optional: false,
|
|
95
|
+
status: "open",
|
|
96
|
+
createdAt: Date.now(),
|
|
97
|
+
},
|
|
98
|
+
],
|
|
99
|
+
};
|
|
100
|
+
for (const listener of listeners) listener();
|
|
101
|
+
await vi.advanceTimersByTimeAsync(300);
|
|
102
|
+
expect(open).toHaveBeenCalledWith(session.browserUrl);
|
|
103
|
+
expect(lines).toEqual([
|
|
104
|
+
expect.objectContaining({ event: "browser.opened", id: "q1" }),
|
|
105
|
+
]);
|
|
106
|
+
await stream.stop();
|
|
107
|
+
} finally {
|
|
108
|
+
vi.useRealTimers();
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
});
|