@itmetsam/samurai-framework 0.22.4 → 0.22.6

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.
Files changed (75) hide show
  1. package/dist/agent/generate-kind.d.ts +5 -0
  2. package/dist/agent/generate-kind.js +45 -0
  3. package/dist/agent/index.d.ts +13 -3
  4. package/dist/agent/index.js +15 -2
  5. package/dist/agent/plan/front-matter.d.ts +9 -0
  6. package/dist/agent/plan/front-matter.js +18 -0
  7. package/dist/agent/plan/guard.d.ts +7 -0
  8. package/dist/agent/plan/guard.js +32 -0
  9. package/dist/agent/plan/plan-file.d.ts +67 -0
  10. package/dist/agent/plan/plan-file.js +229 -0
  11. package/dist/agent/plan/plan-kind.d.ts +5 -0
  12. package/dist/agent/plan/plan-kind.js +28 -0
  13. package/dist/agent/plan/plan-path.d.ts +8 -0
  14. package/dist/agent/plan/plan-path.js +46 -0
  15. package/dist/agent/plan/prompt.d.ts +3 -0
  16. package/dist/agent/plan/prompt.js +37 -0
  17. package/dist/agent/plan/questions.d.ts +5 -0
  18. package/dist/agent/plan/questions.js +22 -0
  19. package/dist/agent/plan/read-plan.d.ts +5 -0
  20. package/dist/agent/plan/read-plan.js +29 -0
  21. package/dist/agent/plan/recorder.d.ts +30 -0
  22. package/dist/agent/plan/recorder.js +59 -0
  23. package/dist/agent/plan/run.d.ts +8 -0
  24. package/dist/agent/plan/run.js +10 -0
  25. package/dist/agent/plan/tools.d.ts +4 -0
  26. package/dist/agent/plan/tools.js +95 -0
  27. package/dist/agent/plan-link.d.ts +8 -0
  28. package/dist/agent/plan-link.js +25 -0
  29. package/dist/agent/prompt.d.ts +6 -0
  30. package/dist/agent/prompt.js +20 -6
  31. package/dist/agent/recording.d.ts +6 -0
  32. package/dist/agent/recording.js +14 -0
  33. package/dist/agent/run-kind.d.ts +48 -0
  34. package/dist/agent/run-kind.js +1 -0
  35. package/dist/agent/run.d.ts +7 -3
  36. package/dist/agent/run.js +19 -27
  37. package/dist/agent/sdk-tools.js +10 -2
  38. package/dist/agent/tools/context.d.ts +12 -5
  39. package/dist/agent/tools/index.js +2 -0
  40. package/dist/agent/tools/undo.d.ts +1 -0
  41. package/dist/agent/tools/undo.js +23 -0
  42. package/dist/agent/types.d.ts +36 -7
  43. package/dist/cli/agent-run.d.ts +43 -0
  44. package/dist/cli/agent-run.js +132 -0
  45. package/dist/cli/args.d.ts +3 -0
  46. package/dist/cli/args.js +3 -0
  47. package/dist/cli/generate.d.ts +4 -8
  48. package/dist/cli/generate.js +18 -109
  49. package/dist/cli/main.js +3 -0
  50. package/dist/cli/plan.d.ts +6 -0
  51. package/dist/cli/plan.js +31 -0
  52. package/dist/cli/usage.d.ts +1 -1
  53. package/dist/cli/usage.js +9 -1
  54. package/dist/config/config.js +2 -0
  55. package/dist/config/defaults.d.ts +2 -0
  56. package/dist/config/defaults.js +2 -0
  57. package/dist/config/plans-dir.d.ts +3 -0
  58. package/dist/config/plans-dir.js +11 -0
  59. package/dist/config/resolved-config.d.ts +1 -0
  60. package/dist/config/resolved-config.js +2 -1
  61. package/dist/recorder/spec-source.d.ts +2 -0
  62. package/dist/recorder/spec-source.js +9 -0
  63. package/dist/runner/registry.js +6 -2
  64. package/dist/runner/test-info.js +1 -0
  65. package/dist/runner/test-io.d.ts +5 -1
  66. package/dist/runner/test-io.js +21 -0
  67. package/dist/steps/edit.d.ts +3 -1
  68. package/dist/steps/edit.js +1 -1
  69. package/dist/steps/model.d.ts +3 -1
  70. package/dist/steps/parse.js +2 -1
  71. package/dist/steps/test-options.d.ts +5 -3
  72. package/dist/steps/test-options.js +49 -0
  73. package/dist/types/config.d.ts +6 -0
  74. package/dist/types/test.d.ts +4 -0
  75. package/package.json +1 -1
@@ -3,18 +3,25 @@ import type Page from "../../browser/page.js";
3
3
  import type { Snapshotter } from "../snapshot.js";
4
4
  import type { AgentEvent } from "../types.js";
5
5
  import type { OriginPolicy } from "./origins.js";
6
- import type { StepRecorder } from "../recording.js";
6
+ import type { Observation, RecorderEvent } from "../../recorder/normalise.js";
7
7
  /** Secrets the agent may use: it knows the names; values are read here and go no further than the page */
8
8
  interface SecretAccess {
9
9
  readonly allowed: readonly string[];
10
10
  /** The value of a secret in the run's environment; undefined when it has none */
11
11
  get(name: string): string | undefined;
12
12
  }
13
+ /** What the shared tools need from the run's record of what was done: a spec writer, or the planner's tally */
14
+ export interface ToolSink {
15
+ /** Takes note of one thing done on the page and returns the events it wrote (none when nothing is written) */
16
+ observe(observation: Observation): RecorderEvent[];
17
+ /** Takes the last `count` steps back and returns how many went */
18
+ undo(count: number): number;
19
+ }
13
20
  /** What the tools of one run share */
14
21
  export interface ToolContext {
15
22
  page: Page;
16
23
  snapshots: Snapshotter;
17
- steps: StepRecorder;
24
+ steps: ToolSink;
18
25
  origins: OriginPolicy;
19
26
  secrets: SecretAccess;
20
27
  /** The model takes images, so `screenshot` works */
@@ -50,14 +57,14 @@ export interface AgentTool {
50
57
  /** Validates `args`, runs the tool and answers; never throws. Secret values are removed from the answer */
51
58
  run(args: unknown): Promise<ToolResult>;
52
59
  }
53
- interface ToolSpec<S extends z.ZodType> {
60
+ interface ToolSpec<S extends z.ZodType, C extends ToolContext> {
54
61
  name: string;
55
62
  description: string;
56
63
  inputSchema: S;
57
- execute(input: z.infer<S>, context: ToolContext): Promise<ToolResult>;
64
+ execute(input: z.infer<S>, context: C): Promise<ToolResult>;
58
65
  }
59
66
  /** The first lines of an error message: enough for the model, without a long call log */
60
67
  export declare function shortMessage(error: unknown, max?: number): string;
61
68
  /** A tool bound to a run. Wraps `execute` so every tool validates, reports and fails the same way */
62
- export declare function defineTool<S extends z.ZodType>(spec: ToolSpec<S>): (context: ToolContext) => AgentTool;
69
+ export declare function defineTool<S extends z.ZodType, C extends ToolContext = ToolContext>(spec: ToolSpec<S, C>): (context: C) => AgentTool;
63
70
  export {};
@@ -5,6 +5,7 @@ import { finish } from "./finish.js";
5
5
  import { navigate } from "./navigate.js";
6
6
  import { screenshot } from "./screenshot.js";
7
7
  import { snapshot } from "./snapshot.js";
8
+ import { undo } from "./undo.js";
8
9
  import { useSecret } from "./use-secret.js";
9
10
  /** The agent's tools, bound to a run. Nothing else reaches the browser, the spec file or the person */
10
11
  export function createTools(context) {
@@ -15,6 +16,7 @@ export function createTools(context) {
15
16
  expectTool,
16
17
  screenshot,
17
18
  useSecret,
19
+ undo,
18
20
  ask,
19
21
  finish,
20
22
  ].map((tool) => tool(context));
@@ -0,0 +1 @@
1
+ export declare const undo: (context: import("./context.js").ToolContext) => import("./context.js").AgentTool;
@@ -0,0 +1,23 @@
1
+ import { z } from "zod";
2
+ import { defineTool } from "./context.js";
3
+ export const undo = defineTool({
4
+ name: "undo",
5
+ description: "Take the last steps you wrote out of the test, for a step that was a mistake or a detour (a failed attempt, a reset). " +
6
+ "It only edits the test: the page stays as it is, so take a new snapshot (or reload) before you carry on.",
7
+ inputSchema: z.object({
8
+ count: z.number().int().min(1).max(20).optional(),
9
+ }),
10
+ async execute({ count }, context) {
11
+ const wanted = count ?? 1;
12
+ const removed = context.steps.undo(wanted);
13
+ if (removed === 0)
14
+ return {
15
+ ok: false,
16
+ error: "undo(): there is no step of yours to remove",
17
+ };
18
+ return {
19
+ ok: true,
20
+ message: `Removed the last ${removed} step${removed === 1 ? "" : "s"} from the test${removed < wanted ? ` (only ${removed} were yours)` : ""}. The page was not changed.`,
21
+ };
22
+ },
23
+ });
@@ -36,6 +36,12 @@ export type AgentEvent =
36
36
  inputTokens: number;
37
37
  outputTokens: number;
38
38
  totalTokens: number;
39
+ }
40
+ /** A planning run recorded step `n` of the plan file */
41
+ | {
42
+ type: "plan-step";
43
+ n: number;
44
+ text: string;
39
45
  } | {
40
46
  type: "ended";
41
47
  result: AgentResult;
@@ -56,17 +62,12 @@ export interface AgentResult {
56
62
  secrets: string[];
57
63
  tokens: number;
58
64
  }
59
- export interface AgentOptions extends ProjectOptions {
65
+ /** What every kind of run takes: the model, the goal, the browser and the limits */
66
+ export interface BaseAgentOptions extends ProjectOptions {
60
67
  /** The model, as an object from an AI SDK provider package. A string is refused */
61
68
  model: AgentModel;
62
69
  /** What the test should do, in the person's words */
63
70
  goal: string;
64
- /** The spec file, absolute or relative to the project folder */
65
- file: string;
66
- /** Add a test with this title to the file (creating it) instead of writing into an existing one */
67
- create?: string;
68
- /** The existing test to write into (title or index), as for `recordSpec` */
69
- test?: string | number;
70
71
  /** Where the browser starts. @default the environment's base URL */
71
72
  url?: string;
72
73
  browser?: SupportedBrowser;
@@ -86,6 +87,34 @@ export interface AgentOptions extends ProjectOptions {
86
87
  signal?: AbortSignal;
87
88
  onEvent?: (event: AgentEvent) => void;
88
89
  }
90
+ export interface AgentOptions extends BaseAgentOptions {
91
+ /** The spec file, absolute or relative to the project folder */
92
+ file: string;
93
+ /** Add a test with this title to the file (creating it) instead of writing into an existing one */
94
+ create?: string;
95
+ /** The existing test to write into (title or index), as for `recordSpec` */
96
+ test?: string | number;
97
+ /** The text of a plan written earlier (see `runPlan`): a draft to follow and check on the live page */
98
+ plan?: string;
99
+ /**
100
+ * The plan file the `plan` text came from, relative to the project folder (an absolute path is made relative).
101
+ * The test is marked with `{ type: "plan", description: <this path> }` in its `annotations` option
102
+ */
103
+ planFile?: string;
104
+ }
105
+ /** A planning run: explores the page and writes a Markdown plan, never a spec, and never submits */
106
+ export interface PlanOptions extends BaseAgentOptions {
107
+ /** The plan file, absolute or relative to the project folder */
108
+ out: string;
109
+ /** Overwrite `out` when it exists. @default false */
110
+ force?: boolean;
111
+ }
112
+ /** How a planning run ended: `file` is the plan, `steps` the plan steps */
113
+ export type PlanResult = Omit<AgentResult, "test">;
114
+ /** A planning run in progress; the same controls as an `AgentSession` */
115
+ export type PlanSession = Omit<AgentSession, "result"> & {
116
+ readonly result: Promise<PlanResult>;
117
+ };
89
118
  /** A run in progress */
90
119
  export interface AgentSession {
91
120
  readonly events: AsyncIterable<AgentEvent>;
@@ -0,0 +1,43 @@
1
+ import { type ParsedCommandLine } from "./args.js";
2
+ import type { AgentResult, AgentSession } from "../agent/index.js";
3
+ import type { Io } from "./io.js";
4
+ /** The agent entry, loaded when `generate` or `plan` runs: it needs the optional packages `ai` and `zod` */
5
+ export type AgentEntry = typeof import("../agent/index.js");
6
+ export interface AgentCommandDeps {
7
+ loadAgent?: () => Promise<AgentEntry>;
8
+ }
9
+ /**
10
+ * What `generate` and `plan` share: the flags both read (browser, origins, secrets, limits, ...), the model and
11
+ * the notice of which provider receives the pages. Throws a usage error for a bad flag.
12
+ */
13
+ export declare function prepareAgentRun({ values }: ParsedCommandLine, io: Io, deps: AgentCommandDeps, command: string): Promise<{
14
+ agent: typeof import("../agent/index.js");
15
+ model: import("../agent/model.js").AgentModel;
16
+ options: {
17
+ signal?: AbortSignal | undefined;
18
+ limits?: {
19
+ maxSteps: number;
20
+ } | undefined;
21
+ secretFields?: string[] | undefined;
22
+ secrets?: string[] | undefined;
23
+ allowedOrigins?: string[] | undefined;
24
+ headless?: boolean | undefined;
25
+ port?: number | undefined;
26
+ browser?: import("../types/browser.js").SupportedBrowser | undefined;
27
+ url?: string | undefined;
28
+ environment?: string | undefined;
29
+ timeout?: number | undefined;
30
+ expectTimeout?: number | undefined;
31
+ };
32
+ }>;
33
+ /**
34
+ * Prints a run's events (or each as a JSON line) and answers its questions in the terminal. Returns the exit code:
35
+ * 0 for a run that finished, 1 for any other end. `summarise` prints the closing text of the command.
36
+ */
37
+ export declare function followRun(session: Pick<AgentSession, "events" | "answer" | "stop"> & {
38
+ result: Promise<{
39
+ reason: string;
40
+ }>;
41
+ }, io: Io, json: boolean, summarise: (result: AgentResult) => void): Promise<number>;
42
+ /** The opening words of the closing text: "Done" or how the run stopped */
43
+ export declare function endingOf(reason: string): string;
@@ -0,0 +1,132 @@
1
+ import { parseRunnerFlags, parseRunOverrides, } from "./args.js";
2
+ async function loadAgentEntry(command) {
3
+ try {
4
+ return await import("../agent/index.js");
5
+ }
6
+ catch (error) {
7
+ if (error.code === "ERR_MODULE_NOT_FOUND")
8
+ throw new Error(`samurai ${command} needs the packages ai and zod, and a provider package for your model; run: npm install ai zod @ai-sdk/anthropic (or @ai-sdk/openai, @ai-sdk/openai-compatible)`);
9
+ throw error;
10
+ }
11
+ }
12
+ function origin(raw) {
13
+ try {
14
+ return new URL(raw).origin;
15
+ }
16
+ catch {
17
+ throw new Error(`--yes-origin: "${raw}" is not a URL such as https://shop.example`);
18
+ }
19
+ }
20
+ /** The model the run uses: `--model`, else `SAMURAI_AI_MODEL`, else the config's `ai.model` */
21
+ async function chosenModel(agent, flag, env) {
22
+ const spec = flag ?? (await agent.defaultModelSpec(env));
23
+ if (!spec)
24
+ throw new Error("No model: pass --model provider:name (anthropic, openai or openai-compatible), set SAMURAI_AI_MODEL, or set ai.model in samurai.config.ts");
25
+ return {
26
+ spec,
27
+ model: await agent.modelFromSpec(spec, { projectDir: process.cwd(), env }),
28
+ };
29
+ }
30
+ /**
31
+ * What `generate` and `plan` share: the flags both read (browser, origins, secrets, limits, ...), the model and
32
+ * the notice of which provider receives the pages. Throws a usage error for a bad flag.
33
+ */
34
+ export async function prepareAgentRun({ values }, io, deps, command) {
35
+ const maxSteps = values["max-steps"] === undefined ? undefined : Number(values["max-steps"]);
36
+ if (maxSteps !== undefined && (!Number.isInteger(maxSteps) || maxSteps < 1))
37
+ throw new Error(`--max-steps must be a whole number from 1, got "${values["max-steps"]}"`);
38
+ const flags = parseRunnerFlags(values);
39
+ if (flags.browsers && flags.browsers.length > 1)
40
+ throw new Error("--browser: the agent uses one browser");
41
+ const allowedOrigins = (values["yes-origin"] ?? []).map(origin);
42
+ const agent = await (deps.loadAgent ?? (() => loadAgentEntry(command)))();
43
+ const { spec, model } = await chosenModel(agent, values.model, io.env);
44
+ if (values.json !== true)
45
+ io.out(`Model ${spec.slice(0, spec.indexOf(":"))}: the pages the agent reads are sent to that provider.\n`);
46
+ return {
47
+ agent,
48
+ model,
49
+ options: {
50
+ ...parseRunOverrides(values, io.env),
51
+ ...(values.url !== undefined && { url: values.url }),
52
+ ...(flags.browsers && { browser: flags.browsers[0] }),
53
+ ...(flags.port !== undefined && { port: flags.port }),
54
+ ...(flags.headless !== undefined && { headless: flags.headless }),
55
+ ...(allowedOrigins.length > 0 && { allowedOrigins }),
56
+ ...(values.secret && { secrets: values.secret }),
57
+ ...(values["secret-field"] && { secretFields: values["secret-field"] }),
58
+ ...(maxSteps !== undefined && { limits: { maxSteps } }),
59
+ ...(io.signal && { signal: io.signal }),
60
+ },
61
+ };
62
+ }
63
+ function summaryOf(args) {
64
+ const text = JSON.stringify(args ?? {});
65
+ return text.length > 100 ? `${text.slice(0, 99)}…` : text;
66
+ }
67
+ /**
68
+ * Prints a run's events (or each as a JSON line) and answers its questions in the terminal. Returns the exit code:
69
+ * 0 for a run that finished, 1 for any other end. `summarise` prints the closing text of the command.
70
+ */
71
+ export async function followRun(session, io, json, summarise) {
72
+ let unanswerable;
73
+ for await (const event of session.events) {
74
+ if (json)
75
+ io.out(`${JSON.stringify(event)}\n`);
76
+ switch (event.type) {
77
+ case "plan":
78
+ if (!json)
79
+ io.out(`${event.text}\n`);
80
+ break;
81
+ case "tool-call":
82
+ if (!json)
83
+ io.out(` · ${event.tool} ${summaryOf(event.args)}\n`);
84
+ break;
85
+ case "step":
86
+ if (!json && event.code !== undefined)
87
+ io.out(` ${event.event.op === "insert" ? "+" : "~"} ${event.code}\n`);
88
+ break;
89
+ case "plan-step":
90
+ if (!json)
91
+ io.out(` + ${event.n}. ${event.text}\n`);
92
+ break;
93
+ case "question": {
94
+ const originQuestion = event.options?.join("/") === "allow/deny";
95
+ if (io.prompt && !json) {
96
+ io.out(`? ${event.question}${event.options ? ` (${event.options.join("/")})` : ""}\n`);
97
+ void io
98
+ .prompt("> ")
99
+ .then((answer) => session.answer(event.id, answer))
100
+ .catch(() => undefined); // the run ended while the question was open
101
+ }
102
+ else if (originQuestion) {
103
+ session.answer(event.id, "deny");
104
+ }
105
+ else {
106
+ unanswerable = event.question;
107
+ session.stop();
108
+ }
109
+ break;
110
+ }
111
+ case "ended":
112
+ if (!json)
113
+ summarise(event.result);
114
+ break;
115
+ default:
116
+ break;
117
+ }
118
+ }
119
+ if (unanswerable !== undefined)
120
+ io.err(`The agent asked "${unanswerable}" and nobody can answer: stdin is not a terminal${json ? " (or --json is set)" : ""}. Run it in a terminal, or put the answer in the goal.\n`);
121
+ return (await session.result).reason === "finished" ? 0 : 1;
122
+ }
123
+ const ENDINGS = {
124
+ stopped: "Stopped.",
125
+ "step-limit": "Stopped at the step limit (--max-steps).",
126
+ "token-limit": "Stopped at the token limit.",
127
+ "time-limit": "Stopped at the time limit.",
128
+ };
129
+ /** The opening words of the closing text: "Done" or how the run stopped */
130
+ export function endingOf(reason) {
131
+ return reason === "finished" ? "Done" : (ENDINGS[reason] ?? reason);
132
+ }
@@ -24,6 +24,9 @@ export declare function parseCommandLine(argv: string[]): {
24
24
  test?: string | undefined;
25
25
  at?: string | undefined;
26
26
  new?: string | undefined;
27
+ plan?: string | undefined;
28
+ out?: string | undefined;
29
+ force?: boolean | undefined;
27
30
  url?: string | undefined;
28
31
  model?: string | undefined;
29
32
  "max-steps"?: string | undefined;
package/dist/cli/args.js CHANGED
@@ -19,6 +19,9 @@ const FLAGS = {
19
19
  test: { type: "string" },
20
20
  at: { type: "string" },
21
21
  new: { type: "string" },
22
+ plan: { type: "string" },
23
+ out: { type: "string" },
24
+ force: { type: "boolean" },
22
25
  url: { type: "string" },
23
26
  model: { type: "string" },
24
27
  "max-steps": { type: "string" },
@@ -1,10 +1,6 @@
1
- import { type ParsedCommandLine } from "./args.js";
1
+ import { type AgentCommandDeps } from "./agent-run.js";
2
+ import type { ParsedCommandLine } from "./args.js";
2
3
  import type { Io } from "./io.js";
3
- /** The agent entry, loaded when `generate` runs: it needs the optional packages `ai` and `zod` */
4
- type AgentEntry = typeof import("../agent/index.js");
5
- export interface GenerateDeps {
6
- loadAgent?: () => Promise<AgentEntry>;
7
- }
4
+ export type GenerateDeps = AgentCommandDeps;
8
5
  /** `samurai generate "<goal>"`: lets the AI agent write a test and prints what it does. Returns the exit code */
9
- export declare function generateCommand({ values, positionals }: ParsedCommandLine, io: Io, deps?: GenerateDeps): Promise<number>;
10
- export {};
6
+ export declare function generateCommand(parsed: ParsedCommandLine, io: Io, deps?: GenerateDeps): Promise<number>;
@@ -1,138 +1,47 @@
1
- import { parseRunnerFlags, parseRunOverrides, } from "./args.js";
2
- const USAGE_LINE = 'Usage: samurai generate "<goal>" --file <spec> [--new <title>] [--test <name|index>] [--url <url>] [--model <provider:name>] [--max-steps <n>] [--yes-origin <origin>] [--secret <NAME>] [--json]';
3
- async function loadAgent() {
4
- try {
5
- return await import("../agent/index.js");
6
- }
7
- catch (error) {
8
- if (error.code === "ERR_MODULE_NOT_FOUND")
9
- throw new Error("samurai generate needs the packages ai and zod, and a provider package for your model; run: npm install ai zod @ai-sdk/anthropic (or @ai-sdk/openai, @ai-sdk/openai-compatible)");
10
- throw error;
11
- }
12
- }
13
- function origin(raw) {
14
- try {
15
- return new URL(raw).origin;
16
- }
17
- catch {
18
- throw new Error(`--yes-origin: "${raw}" is not a URL such as https://shop.example`);
19
- }
20
- }
21
- /** The model the run uses: `--model`, else `SAMURAI_AI_MODEL`, else the config's `ai.model` */
22
- async function chosenModel(agent, flag, env) {
23
- const spec = flag ?? (await agent.defaultModelSpec(env));
24
- if (!spec)
25
- throw new Error("No model: pass --model provider:name (anthropic, openai or openai-compatible), set SAMURAI_AI_MODEL, or set ai.model in samurai.config.ts");
26
- return {
27
- spec,
28
- model: await agent.modelFromSpec(spec, { projectDir: process.cwd(), env }),
29
- };
30
- }
1
+ import { endingOf, followRun, prepareAgentRun, } from "./agent-run.js";
2
+ const USAGE_LINE = 'Usage: samurai generate "<goal>" --file <spec> [--new <title>] [--test <name|index>] [--plan <plan.md>] [--url <url>] [--model <provider:name>] [--max-steps <n>] [--yes-origin <origin>] [--secret <NAME>] [--json]';
31
3
  /** `samurai generate "<goal>"`: lets the AI agent write a test and prints what it does. Returns the exit code */
32
- export async function generateCommand({ values, positionals }, io, deps = {}) {
4
+ export async function generateCommand(parsed, io, deps = {}) {
5
+ const { values, positionals } = parsed;
33
6
  const goal = positionals[0];
34
7
  if (!goal)
35
8
  throw new Error(`Which goal? ${USAGE_LINE}`);
36
9
  const files = values.file ?? [];
37
10
  if (files.length !== 1)
38
11
  throw new Error(`Give exactly one --file <spec>. ${USAGE_LINE}`);
39
- const maxSteps = values["max-steps"] === undefined ? undefined : Number(values["max-steps"]);
40
- if (maxSteps !== undefined && (!Number.isInteger(maxSteps) || maxSteps < 1))
41
- throw new Error(`--max-steps must be a whole number from 1, got "${values["max-steps"]}"`);
42
- const flags = parseRunnerFlags(values);
43
- if (flags.browsers && flags.browsers.length > 1)
44
- throw new Error("--browser: the agent uses one browser");
45
12
  const test = values.test === undefined
46
13
  ? undefined
47
14
  : /^\d+$/.test(values.test)
48
15
  ? Number(values.test)
49
16
  : values.test;
50
- const allowedOrigins = (values["yes-origin"] ?? []).map(origin);
51
- const agent = await (deps.loadAgent ?? loadAgent)();
52
- const { spec, model } = await chosenModel(agent, values.model, io.env);
53
- const json = values.json === true;
54
- if (!json)
55
- io.out(`Model ${spec.slice(0, spec.indexOf(":"))}: the pages the agent reads are sent to that provider.\n`);
17
+ const { agent, model, options } = await prepareAgentRun(parsed, io, deps, "generate");
18
+ const plan = values.plan === undefined ? undefined : readPlanFlag(agent, values.plan);
56
19
  const session = agent.runAgent({
57
- ...parseRunOverrides(values, io.env),
20
+ ...options,
58
21
  model,
59
22
  goal,
60
23
  file: files[0],
61
24
  ...(test !== undefined && { test }),
62
25
  ...(values.new !== undefined && { create: values.new }),
63
- ...(values.url !== undefined && { url: values.url }),
64
- ...(flags.browsers && { browser: flags.browsers[0] }),
65
- ...(flags.port !== undefined && { port: flags.port }),
66
- ...(flags.headless !== undefined && { headless: flags.headless }),
67
- ...(allowedOrigins.length > 0 && { allowedOrigins }),
68
- ...(values.secret && { secrets: values.secret }),
69
- ...(values["secret-field"] && { secretFields: values["secret-field"] }),
70
- ...(maxSteps !== undefined && { limits: { maxSteps } }),
71
- ...(io.signal && { signal: io.signal }),
26
+ ...(plan !== undefined && { plan, planFile: values.plan }),
72
27
  });
73
- let unanswerable;
74
- for await (const event of session.events) {
75
- if (json)
76
- io.out(`${JSON.stringify(event)}\n`);
77
- switch (event.type) {
78
- case "plan":
79
- if (!json)
80
- io.out(`${event.text}\n`);
81
- break;
82
- case "tool-call":
83
- if (!json)
84
- io.out(` · ${event.tool} ${summaryOf(event.args)}\n`);
85
- break;
86
- case "step":
87
- if (!json && event.code !== undefined)
88
- io.out(` ${event.event.op === "insert" ? "+" : "~"} ${event.code}\n`);
89
- break;
90
- case "question": {
91
- const originQuestion = event.options?.join("/") === "allow/deny";
92
- if (io.prompt && !json) {
93
- io.out(`? ${event.question}${event.options ? ` (${event.options.join("/")})` : ""}\n`);
94
- void io
95
- .prompt("> ")
96
- .then((answer) => session.answer(event.id, answer))
97
- .catch(() => undefined); // the run ended while the question was open
98
- }
99
- else if (originQuestion) {
100
- session.answer(event.id, "deny");
101
- }
102
- else {
103
- unanswerable = event.question;
104
- session.stop();
105
- }
106
- break;
107
- }
108
- case "ended":
109
- if (!json)
110
- summarise(io, event.result);
111
- break;
112
- default:
113
- break;
114
- }
115
- }
116
- if (unanswerable !== undefined)
117
- io.err(`The agent asked "${unanswerable}" and nobody can answer: stdin is not a terminal${json ? " (or --json is set)" : ""}. Run it in a terminal, or put the answer in the goal.\n`);
118
- return (await session.result).reason === "finished" ? 0 : 1;
28
+ return followRun(session, io, values.json === true, (result) => summarise(io, result));
119
29
  }
120
- function summaryOf(args) {
121
- const text = JSON.stringify(args ?? {});
122
- return text.length > 100 ? `${text.slice(0, 99)}…` : text;
30
+ /** The text of `--plan <file>`; a file that cannot be used is a usage error that names it */
31
+ function readPlanFlag(agent, file) {
32
+ try {
33
+ return agent.readPlan(file);
34
+ }
35
+ catch (error) {
36
+ throw new Error(`--plan: ${error.message.replace(/^readPlan\(\): /, "")}`);
37
+ }
123
38
  }
124
- const ENDINGS = {
125
- stopped: "Stopped.",
126
- "step-limit": "Stopped at the step limit (--max-steps).",
127
- "token-limit": "Stopped at the token limit.",
128
- "time-limit": "Stopped at the time limit.",
129
- };
130
39
  function summarise(io, result) {
131
40
  const base = `"${result.test}" has ${result.steps} ${result.steps === 1 ? "step" : "steps"} in ${result.file}.`;
132
41
  if (result.reason === "error")
133
42
  io.err(`\nThe run failed: ${result.error ?? "unknown error"}\n${base}\n`);
134
43
  else
135
- io.out(`\n${result.reason === "finished" ? "Done" : (ENDINGS[result.reason] ?? result.reason)} ${base}\n${result.summary ? `${result.summary}\n` : ""}`);
44
+ io.out(`\n${endingOf(result.reason)} ${base}\n${result.summary ? `${result.summary}\n` : ""}`);
136
45
  if (result.secrets.length > 0)
137
46
  io.out(`Set ${result.secrets.map((name) => `SAMURAI_SECRET_${name}`).join(", ")} (environment variable or .env.<environment>) before running it.\n`);
138
47
  io.out(`${result.tokens} tokens used. Review the steps before you run the test.\n`);
package/dist/cli/main.js CHANGED
@@ -5,6 +5,7 @@ import { browsersCommand } from "./browsers.js";
5
5
  import { flowCommand } from "./flow.js";
6
6
  import { generateCommand } from "./generate.js";
7
7
  import { initProject } from "./init.js";
8
+ import { planCommand } from "./plan.js";
8
9
  import { recordCommand } from "./record.js";
9
10
  import { USAGE } from "./usage.js";
10
11
  import { frameworkVersion } from "./version.js";
@@ -39,6 +40,8 @@ export async function main(argv, io) {
39
40
  }
40
41
  if (command === "generate")
41
42
  return await generateCommand({ values, positionals }, io);
43
+ if (command === "plan")
44
+ return await planCommand({ values, positionals }, io);
42
45
  if (command === "record")
43
46
  return await recordCommand({ values, positionals }, io);
44
47
  if (command !== "run" && command !== "list") {
@@ -0,0 +1,6 @@
1
+ import { type AgentCommandDeps } from "./agent-run.js";
2
+ import type { ParsedCommandLine } from "./args.js";
3
+ import type { Io } from "./io.js";
4
+ export type PlanDeps = AgentCommandDeps;
5
+ /** `samurai plan "<goal>"`: without `--out` the plan goes to `<plansDir>/<slug-of-goal>.plan.md`. Lets the AI agent explore the page and write a test plan, without submitting anything */
6
+ export declare function planCommand(parsed: ParsedCommandLine, io: Io, deps?: PlanDeps): Promise<number>;
@@ -0,0 +1,31 @@
1
+ import { endingOf, followRun, prepareAgentRun, } from "./agent-run.js";
2
+ const USAGE_LINE = 'Usage: samurai plan "<goal>" [--out <plan.md>] [--force] [--url <url>] [--model <provider:name>] [--max-steps <n>] [--yes-origin <origin>] [--secret <NAME>] [--json]';
3
+ /** `samurai plan "<goal>"`: without `--out` the plan goes to `<plansDir>/<slug-of-goal>.plan.md`. Lets the AI agent explore the page and write a test plan, without submitting anything */
4
+ export async function planCommand(parsed, io, deps = {}) {
5
+ const { values, positionals } = parsed;
6
+ const goal = positionals[0];
7
+ if (!goal)
8
+ throw new Error(`Which goal? ${USAGE_LINE}`);
9
+ const { agent, model, options } = await prepareAgentRun(parsed, io, deps, "plan");
10
+ const out = values.out === undefined || values.out === ""
11
+ ? await agent.defaultPlanFile(goal)
12
+ : values.out;
13
+ if (values.out === undefined && values.json !== true)
14
+ io.out(`The plan will be written to ${out}\n`);
15
+ const session = agent.runPlan({
16
+ ...options,
17
+ model,
18
+ goal,
19
+ out,
20
+ ...(values.force === true && { force: true }),
21
+ });
22
+ return followRun(session, io, values.json === true, (result) => summarise(io, result));
23
+ }
24
+ function summarise(io, result) {
25
+ const base = `The plan in ${result.file} has ${result.steps} ${result.steps === 1 ? "step" : "steps"}.`;
26
+ if (result.reason === "error")
27
+ io.err(`\nThe run failed: ${result.error ?? "unknown error"}\n${base}\n`);
28
+ else
29
+ io.out(`\n${endingOf(result.reason)} ${base}\n${result.summary ? `${result.summary}\n` : ""}`);
30
+ io.out(`${result.tokens} tokens used. Read the plan, fix what is wrong, then: samurai generate "<goal>" --plan ${result.file} --file <spec>\n`);
31
+ }
@@ -1 +1 @@
1
- export declare const USAGE = "samurai: browser tests over WebDriver BiDi\n\nUsage:\n samurai run [options] Run the tests (the default command)\n samurai list [options] List the tests without running them\n samurai record <spec> Open a browser and record what you do into a spec file\n samurai generate \"<goal>\" Let an AI agent write a test into a spec file (needs ai, zod and a provider)\n samurai init [folder] Scaffold a project: config, an example test, .env.example\n samurai flow <command> List, check and run the flows in ./flows (see below)\n samurai browsers [command] List, install and remove the browsers SAMURAI launches\n\nOptions for run and list:\n --env <name> Environment to run against (or SAMURAI_ENV)\n --file <spec> Only this spec file; repeatable\n --grep <text> Only tests whose full name contains the text\n --group <name> Only the tests of this group of the config's groups; repeat it for several\n --browser <name> Run in this browser (firefox or chrome) instead of the config's\n browsers; repeat it for more than one\n --json Machine-readable output: JSON lines for run, one array for list\n\nOptions for run:\n --timeout <ms> Time one test may take\n --expect-timeout <ms> Time actions and assertions retry\n --headless, --no-headless Run the browser without a window, or with one.\n Default: the config's use.headless, else headless when CI is set\n --port <n> Browser debugging port (default 9223)\n --workers <n|n%> Tests that run at the same time, each in a worker process: a number\n from 1, or a share of the CPUs like 50%. Default: the config's\n workers (or SAMURAI_WORKERS), else half the CPUs\n --retries <n> Run a failing test again up to n times, each with a fresh browser\n (or SAMURAI_RETRIES; default 0)\n --shard <current/total> Run one slice of the selected tests, to split a run over machines:\n --shard=2/4 runs the second of four. Whole files (and serial\n blocks) go to one shard; an empty shard succeeds\n\nOptions for record (also --env, --port, --json; always with a window):\n --browser <name> Record in this browser (default: the first of the config's browsers)\n --new <title> Add a test with this title (creates the spec file if needed)\n --test <name|index> Record into this test; not needed when the file has one\n --at <n> Insert from step n (default: after the last step)\n --url <url> Page to start on (default: the environment's baseURL)\n Stop with Ctrl+C or by closing the browser window.\n\nOptions for generate (also --env, --browser, --port, --headless, --json):\n --file <spec> The spec file to write into (required, once)\n --new <title> Add a test with this title (creates the file if needed)\n --test <name|index> Write into this test; not needed when the file has one\n --url <url> Page to start on (default: the environment's baseURL)\n --model <provider:name> anthropic, openai or openai-compatible, for example\n anthropic:claude-sonnet-4-5. Default: SAMURAI_AI_MODEL, then the\n config's ai.model. Keys: ANTHROPIC_API_KEY, OPENAI_API_KEY,\n SAMURAI_AI_API_KEY (+ SAMURAI_AI_BASE_URL for openai-compatible)\n --max-steps <n> Most model calls in the run (default 40)\n --yes-origin <origin> A site the agent may open without asking; repeatable\n --secret <NAME> A secret the agent may type (it never sees the value); repeatable\n --secret-field <css> A field whose value is never shown to the model; repeatable\n Questions are asked in the terminal; without one (or with --json) an unknown site is refused\n and any other question ends the run.\n\n -h, --help Show this help\n -v, --version Show the version\n\nExit code: 0 when every test passed (generate: the agent finished), 1 when a test failed (generate: it ended at a limit, was stopped or failed), 2 for a usage or configuration error.\n\nsamurai flow:\n list The flows in ./flows\n check [<flow>...] Check flows without running them\n run <flow>... | --all Run flows; each writes result/flows/<id>.json\n Options for run: --env, --headless, --timeout, --expect-timeout, --port, --json, --allow-unsupported\n Run `samurai flow` alone for the details.\n\nsamurai browsers:\n list The pinned browsers and every build in the cache (the default)\n install [firefox|chrome|all]\n Download the pinned builds (default: all)\n uninstall <name> [--build <id>] | --unused\n\nRun it in the project folder, the one holding samurai.config.ts.\n";
1
+ export declare const USAGE = "samurai: browser tests over WebDriver BiDi\n\nUsage:\n samurai run [options] Run the tests (the default command)\n samurai list [options] List the tests without running them\n samurai record <spec> Open a browser and record what you do into a spec file\n samurai plan \"<goal>\" Let an AI agent explore a page and write a test plan (Markdown); it submits nothing\n samurai generate \"<goal>\" Let an AI agent write a test into a spec file (needs ai, zod and a provider)\n samurai init [folder] Scaffold a project: config, an example test, .env.example\n samurai flow <command> List, check and run the flows in ./flows (see below)\n samurai browsers [command] List, install and remove the browsers SAMURAI launches\n\nOptions for run and list:\n --env <name> Environment to run against (or SAMURAI_ENV)\n --file <spec> Only this spec file; repeatable\n --grep <text> Only tests whose full name contains the text\n --group <name> Only the tests of this group of the config's groups; repeat it for several\n --browser <name> Run in this browser (firefox or chrome) instead of the config's\n browsers; repeat it for more than one\n --json Machine-readable output: JSON lines for run, one array for list\n\nOptions for run:\n --timeout <ms> Time one test may take\n --expect-timeout <ms> Time actions and assertions retry\n --headless, --no-headless Run the browser without a window, or with one.\n Default: the config's use.headless, else headless when CI is set\n --port <n> Browser debugging port (default 9223)\n --workers <n|n%> Tests that run at the same time, each in a worker process: a number\n from 1, or a share of the CPUs like 50%. Default: the config's\n workers (or SAMURAI_WORKERS), else half the CPUs\n --retries <n> Run a failing test again up to n times, each with a fresh browser\n (or SAMURAI_RETRIES; default 0)\n --shard <current/total> Run one slice of the selected tests, to split a run over machines:\n --shard=2/4 runs the second of four. Whole files (and serial\n blocks) go to one shard; an empty shard succeeds\n\nOptions for record (also --env, --port, --json; always with a window):\n --browser <name> Record in this browser (default: the first of the config's browsers)\n --new <title> Add a test with this title (creates the spec file if needed)\n --test <name|index> Record into this test; not needed when the file has one\n --at <n> Insert from step n (default: after the last step)\n --url <url> Page to start on (default: the environment's baseURL)\n Stop with Ctrl+C or by closing the browser window.\n\nOptions for plan (also --env, --browser, --port, --headless, --json and the model, limit and\norigin options of generate below):\n --out <plan.md> The plan file to write; written after every step. Without it: <plansDir>/<goal-slug>.plan.md\n --force Replace --out when it exists\n The page is read-only while planning: every request that is not a GET is blocked.\n\nOptions for generate (also --env, --browser, --port, --headless, --json):\n --file <spec> The spec file to write into (required, once)\n --plan <plan.md> A plan from samurai plan: the agent follows it and checks each step\n --new <title> Add a test with this title (creates the file if needed)\n --test <name|index> Write into this test; not needed when the file has one\n --url <url> Page to start on (default: the environment's baseURL)\n --model <provider:name> anthropic, openai or openai-compatible, for example\n anthropic:claude-sonnet-4-5. Default: SAMURAI_AI_MODEL, then the\n config's ai.model. Keys: ANTHROPIC_API_KEY, OPENAI_API_KEY,\n SAMURAI_AI_API_KEY (+ SAMURAI_AI_BASE_URL for openai-compatible)\n --max-steps <n> Most model calls in the run (default 40)\n --yes-origin <origin> A site the agent may open without asking; repeatable\n --secret <NAME> A secret the agent may type (it never sees the value); repeatable\n --secret-field <css> A field whose value is never shown to the model; repeatable\n Questions are asked in the terminal; without one (or with --json) an unknown site is refused\n and any other question ends the run.\n\n -h, --help Show this help\n -v, --version Show the version\n\nExit code: 0 when every test passed (generate, plan: the agent finished), 1 when a test failed (generate, plan: it ended at a limit, was stopped or failed), 2 for a usage or configuration error.\n\nsamurai flow:\n list The flows in ./flows\n check [<flow>...] Check flows without running them\n run <flow>... | --all Run flows; each writes result/flows/<id>.json\n Options for run: --env, --headless, --timeout, --expect-timeout, --port, --json, --allow-unsupported\n Run `samurai flow` alone for the details.\n\nsamurai browsers:\n list The pinned browsers and every build in the cache (the default)\n install [firefox|chrome|all]\n Download the pinned builds (default: all)\n uninstall <name> [--build <id>] | --unused\n\nRun it in the project folder, the one holding samurai.config.ts.\n";
package/dist/cli/usage.js CHANGED
@@ -5,6 +5,7 @@ Usage:
5
5
  samurai run [options] Run the tests (the default command)
6
6
  samurai list [options] List the tests without running them
7
7
  samurai record <spec> Open a browser and record what you do into a spec file
8
+ samurai plan "<goal>" Let an AI agent explore a page and write a test plan (Markdown); it submits nothing
8
9
  samurai generate "<goal>" Let an AI agent write a test into a spec file (needs ai, zod and a provider)
9
10
  samurai init [folder] Scaffold a project: config, an example test, .env.example
10
11
  samurai flow <command> List, check and run the flows in ./flows (see below)
@@ -42,8 +43,15 @@ Options for record (also --env, --port, --json; always with a window):
42
43
  --url <url> Page to start on (default: the environment's baseURL)
43
44
  Stop with Ctrl+C or by closing the browser window.
44
45
 
46
+ Options for plan (also --env, --browser, --port, --headless, --json and the model, limit and
47
+ origin options of generate below):
48
+ --out <plan.md> The plan file to write; written after every step. Without it: <plansDir>/<goal-slug>.plan.md
49
+ --force Replace --out when it exists
50
+ The page is read-only while planning: every request that is not a GET is blocked.
51
+
45
52
  Options for generate (also --env, --browser, --port, --headless, --json):
46
53
  --file <spec> The spec file to write into (required, once)
54
+ --plan <plan.md> A plan from samurai plan: the agent follows it and checks each step
47
55
  --new <title> Add a test with this title (creates the file if needed)
48
56
  --test <name|index> Write into this test; not needed when the file has one
49
57
  --url <url> Page to start on (default: the environment's baseURL)
@@ -61,7 +69,7 @@ Options for generate (also --env, --browser, --port, --headless, --json):
61
69
  -h, --help Show this help
62
70
  -v, --version Show the version
63
71
 
64
- Exit code: 0 when every test passed (generate: the agent finished), 1 when a test failed (generate: it ended at a limit, was stopped or failed), 2 for a usage or configuration error.
72
+ Exit code: 0 when every test passed (generate, plan: the agent finished), 1 when a test failed (generate, plan: it ended at a limit, was stopped or failed), 2 for a usage or configuration error.
65
73
 
66
74
  samurai flow:
67
75
  list The flows in ./flows