@equinor/fusion-framework-cli-plugin-copilot 1.0.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.
Files changed (186) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/LICENSE +21 -0
  3. package/README.md +228 -0
  4. package/dist/esm/commands/app/command.js +176 -0
  5. package/dist/esm/commands/app/command.js.map +1 -0
  6. package/dist/esm/commands/app/eval.js +92 -0
  7. package/dist/esm/commands/app/eval.js.map +1 -0
  8. package/dist/esm/commands/app/format.js +93 -0
  9. package/dist/esm/commands/app/format.js.map +1 -0
  10. package/dist/esm/commands/app/login.js +71 -0
  11. package/dist/esm/commands/app/login.js.map +1 -0
  12. package/dist/esm/commands/app/prompts/execute-step.prompt.js +44 -0
  13. package/dist/esm/commands/app/prompts/execute-step.prompt.js.map +1 -0
  14. package/dist/esm/commands/app/prompts/judge.prompt.js +116 -0
  15. package/dist/esm/commands/app/prompts/judge.prompt.js.map +1 -0
  16. package/dist/esm/commands/app/prompts/plan.prompt.js +47 -0
  17. package/dist/esm/commands/app/prompts/plan.prompt.js.map +1 -0
  18. package/dist/esm/commands/app/prompts/step.prompt.js +42 -0
  19. package/dist/esm/commands/app/prompts/step.prompt.js.map +1 -0
  20. package/dist/esm/commands/app/prompts/system.dev-server.prompt.js +21 -0
  21. package/dist/esm/commands/app/prompts/system.dev-server.prompt.js.map +1 -0
  22. package/dist/esm/commands/app/prompts/system.prompt.js +77 -0
  23. package/dist/esm/commands/app/prompts/system.prompt.js.map +1 -0
  24. package/dist/esm/commands/app/server.js +83 -0
  25. package/dist/esm/commands/app/server.js.map +1 -0
  26. package/dist/esm/commands/app/session-logger.js +171 -0
  27. package/dist/esm/commands/app/session-logger.js.map +1 -0
  28. package/dist/esm/commands/app/tools/click.js +27 -0
  29. package/dist/esm/commands/app/tools/click.js.map +1 -0
  30. package/dist/esm/commands/app/tools/errors.js +24 -0
  31. package/dist/esm/commands/app/tools/errors.js.map +1 -0
  32. package/dist/esm/commands/app/tools/eval-js.js +36 -0
  33. package/dist/esm/commands/app/tools/eval-js.js.map +1 -0
  34. package/dist/esm/commands/app/tools/fill.js +28 -0
  35. package/dist/esm/commands/app/tools/fill.js.map +1 -0
  36. package/dist/esm/commands/app/tools/find.js +44 -0
  37. package/dist/esm/commands/app/tools/find.js.map +1 -0
  38. package/dist/esm/commands/app/tools/get-styles.js +36 -0
  39. package/dist/esm/commands/app/tools/get-styles.js.map +1 -0
  40. package/dist/esm/commands/app/tools/get-url.js +24 -0
  41. package/dist/esm/commands/app/tools/get-url.js.map +1 -0
  42. package/dist/esm/commands/app/tools/go-back.js +15 -0
  43. package/dist/esm/commands/app/tools/go-back.js.map +1 -0
  44. package/dist/esm/commands/app/tools/hover.js +24 -0
  45. package/dist/esm/commands/app/tools/hover.js.map +1 -0
  46. package/dist/esm/commands/app/tools/index.js +32 -0
  47. package/dist/esm/commands/app/tools/index.js.map +1 -0
  48. package/dist/esm/commands/app/tools/navigate.js +31 -0
  49. package/dist/esm/commands/app/tools/navigate.js.map +1 -0
  50. package/dist/esm/commands/app/tools/press-key.js +24 -0
  51. package/dist/esm/commands/app/tools/press-key.js.map +1 -0
  52. package/dist/esm/commands/app/tools/read-file.js +98 -0
  53. package/dist/esm/commands/app/tools/read-file.js.map +1 -0
  54. package/dist/esm/commands/app/tools/registry.js +34 -0
  55. package/dist/esm/commands/app/tools/registry.js.map +1 -0
  56. package/dist/esm/commands/app/tools/reload.js +15 -0
  57. package/dist/esm/commands/app/tools/reload.js.map +1 -0
  58. package/dist/esm/commands/app/tools/screenshot.js +133 -0
  59. package/dist/esm/commands/app/tools/screenshot.js.map +1 -0
  60. package/dist/esm/commands/app/tools/scroll.js +41 -0
  61. package/dist/esm/commands/app/tools/scroll.js.map +1 -0
  62. package/dist/esm/commands/app/tools/select.js +25 -0
  63. package/dist/esm/commands/app/tools/select.js.map +1 -0
  64. package/dist/esm/commands/app/tools/snapshot.js +24 -0
  65. package/dist/esm/commands/app/tools/snapshot.js.map +1 -0
  66. package/dist/esm/commands/app/tools/type-text.js +29 -0
  67. package/dist/esm/commands/app/tools/type-text.js.map +1 -0
  68. package/dist/esm/commands/app/tools/types.js +2 -0
  69. package/dist/esm/commands/app/tools/types.js.map +1 -0
  70. package/dist/esm/commands/app/tools/wait.js +39 -0
  71. package/dist/esm/commands/app/tools/wait.js.map +1 -0
  72. package/dist/esm/commands/app/tools/write-file.js +98 -0
  73. package/dist/esm/commands/app/tools/write-file.js.map +1 -0
  74. package/dist/esm/commands/app/types.js +2 -0
  75. package/dist/esm/commands/app/types.js.map +1 -0
  76. package/dist/esm/eval-resolve.js +83 -0
  77. package/dist/esm/eval-resolve.js.map +1 -0
  78. package/dist/esm/index.js +18 -0
  79. package/dist/esm/index.js.map +1 -0
  80. package/dist/esm/utils/agent-browser.js +67 -0
  81. package/dist/esm/utils/agent-browser.js.map +1 -0
  82. package/dist/esm/utils/browser-poll.js +26 -0
  83. package/dist/esm/utils/browser-poll.js.map +1 -0
  84. package/dist/esm/utils/daemon.js +83 -0
  85. package/dist/esm/utils/daemon.js.map +1 -0
  86. package/dist/esm/utils/index.js +6 -0
  87. package/dist/esm/utils/index.js.map +1 -0
  88. package/dist/esm/utils/process.js +30 -0
  89. package/dist/esm/utils/process.js.map +1 -0
  90. package/dist/esm/utils/server.js +65 -0
  91. package/dist/esm/utils/server.js.map +1 -0
  92. package/dist/esm/version.js +3 -0
  93. package/dist/esm/version.js.map +1 -0
  94. package/dist/tsconfig.tsbuildinfo +1 -0
  95. package/dist/types/commands/app/command.d.ts +7 -0
  96. package/dist/types/commands/app/eval.d.ts +9 -0
  97. package/dist/types/commands/app/format.d.ts +14 -0
  98. package/dist/types/commands/app/login.d.ts +14 -0
  99. package/dist/types/commands/app/prompts/execute-step.prompt.d.ts +13 -0
  100. package/dist/types/commands/app/prompts/judge.prompt.d.ts +15 -0
  101. package/dist/types/commands/app/prompts/plan.prompt.d.ts +13 -0
  102. package/dist/types/commands/app/prompts/step.prompt.d.ts +12 -0
  103. package/dist/types/commands/app/prompts/system.dev-server.prompt.d.ts +12 -0
  104. package/dist/types/commands/app/prompts/system.prompt.d.ts +12 -0
  105. package/dist/types/commands/app/server.d.ts +26 -0
  106. package/dist/types/commands/app/session-logger.d.ts +14 -0
  107. package/dist/types/commands/app/tools/click.d.ts +9 -0
  108. package/dist/types/commands/app/tools/errors.d.ts +9 -0
  109. package/dist/types/commands/app/tools/eval-js.d.ts +13 -0
  110. package/dist/types/commands/app/tools/fill.d.ts +9 -0
  111. package/dist/types/commands/app/tools/find.d.ts +9 -0
  112. package/dist/types/commands/app/tools/get-styles.d.ts +13 -0
  113. package/dist/types/commands/app/tools/get-url.d.ts +9 -0
  114. package/dist/types/commands/app/tools/go-back.d.ts +9 -0
  115. package/dist/types/commands/app/tools/hover.d.ts +9 -0
  116. package/dist/types/commands/app/tools/index.d.ts +9 -0
  117. package/dist/types/commands/app/tools/navigate.d.ts +9 -0
  118. package/dist/types/commands/app/tools/press-key.d.ts +9 -0
  119. package/dist/types/commands/app/tools/read-file.d.ts +13 -0
  120. package/dist/types/commands/app/tools/registry.d.ts +28 -0
  121. package/dist/types/commands/app/tools/reload.d.ts +9 -0
  122. package/dist/types/commands/app/tools/screenshot.d.ts +9 -0
  123. package/dist/types/commands/app/tools/scroll.d.ts +9 -0
  124. package/dist/types/commands/app/tools/select.d.ts +9 -0
  125. package/dist/types/commands/app/tools/snapshot.d.ts +9 -0
  126. package/dist/types/commands/app/tools/type-text.d.ts +9 -0
  127. package/dist/types/commands/app/tools/types.d.ts +12 -0
  128. package/dist/types/commands/app/tools/wait.d.ts +9 -0
  129. package/dist/types/commands/app/tools/write-file.d.ts +19 -0
  130. package/dist/types/commands/app/types.d.ts +68 -0
  131. package/dist/types/eval-resolve.d.ts +31 -0
  132. package/dist/types/index.d.ts +15 -0
  133. package/dist/types/utils/agent-browser.d.ts +26 -0
  134. package/dist/types/utils/browser-poll.d.ts +9 -0
  135. package/dist/types/utils/daemon.d.ts +16 -0
  136. package/dist/types/utils/index.d.ts +5 -0
  137. package/dist/types/utils/process.d.ts +21 -0
  138. package/dist/types/utils/server.d.ts +26 -0
  139. package/dist/types/version.d.ts +1 -0
  140. package/package.json +57 -0
  141. package/src/commands/app/command.ts +204 -0
  142. package/src/commands/app/eval.ts +114 -0
  143. package/src/commands/app/format.ts +110 -0
  144. package/src/commands/app/login.ts +92 -0
  145. package/src/commands/app/prompts/execute-step.prompt.ts +48 -0
  146. package/src/commands/app/prompts/judge.prompt.ts +121 -0
  147. package/src/commands/app/prompts/plan.prompt.ts +48 -0
  148. package/src/commands/app/prompts/step.prompt.ts +45 -0
  149. package/src/commands/app/prompts/system.dev-server.prompt.ts +22 -0
  150. package/src/commands/app/prompts/system.prompt.ts +78 -0
  151. package/src/commands/app/server.ts +105 -0
  152. package/src/commands/app/session-logger.ts +187 -0
  153. package/src/commands/app/tools/click.ts +28 -0
  154. package/src/commands/app/tools/errors.ts +26 -0
  155. package/src/commands/app/tools/eval-js.ts +40 -0
  156. package/src/commands/app/tools/fill.ts +29 -0
  157. package/src/commands/app/tools/find.ts +49 -0
  158. package/src/commands/app/tools/get-styles.ts +40 -0
  159. package/src/commands/app/tools/get-url.ts +26 -0
  160. package/src/commands/app/tools/go-back.ts +16 -0
  161. package/src/commands/app/tools/hover.ts +25 -0
  162. package/src/commands/app/tools/index.ts +57 -0
  163. package/src/commands/app/tools/navigate.ts +32 -0
  164. package/src/commands/app/tools/press-key.ts +25 -0
  165. package/src/commands/app/tools/read-file.ts +113 -0
  166. package/src/commands/app/tools/registry.ts +38 -0
  167. package/src/commands/app/tools/reload.ts +16 -0
  168. package/src/commands/app/tools/screenshot.ts +168 -0
  169. package/src/commands/app/tools/scroll.ts +45 -0
  170. package/src/commands/app/tools/select.ts +26 -0
  171. package/src/commands/app/tools/snapshot.ts +27 -0
  172. package/src/commands/app/tools/type-text.ts +30 -0
  173. package/src/commands/app/tools/types.ts +15 -0
  174. package/src/commands/app/tools/wait.ts +43 -0
  175. package/src/commands/app/tools/write-file.ts +104 -0
  176. package/src/commands/app/types.ts +76 -0
  177. package/src/eval-resolve.ts +113 -0
  178. package/src/index.ts +20 -0
  179. package/src/utils/agent-browser.ts +66 -0
  180. package/src/utils/browser-poll.ts +27 -0
  181. package/src/utils/daemon.ts +84 -0
  182. package/src/utils/index.ts +5 -0
  183. package/src/utils/process.ts +33 -0
  184. package/src/utils/server.ts +66 -0
  185. package/src/version.ts +2 -0
  186. package/tsconfig.json +13 -0
@@ -0,0 +1,204 @@
1
+ import { createCommand } from 'commander';
2
+ import { basename } from 'node:path';
3
+
4
+ import { resetDaemon, cleanup, createRunDir } from '../../utils/index.js';
5
+ import { runLogin } from './login.js';
6
+ import { resolveEvalCommandInput, resolveEvalFiles } from '../../eval-resolve.js';
7
+ import { createSession } from './eval.js';
8
+ import { startAppServer } from './server.js';
9
+ import { attachSessionLogger } from './session-logger.js';
10
+ import type { CopilotEvalOptions, Plan, Verdict } from './types.js';
11
+ import { formatVerdict } from './format.js';
12
+ import { existsSync, readFileSync } from 'node:fs';
13
+ import { createPlanPrompt } from './prompts/plan.prompt.js';
14
+ import { createStepPrompt } from './prompts/step.prompt.js';
15
+ import { createJudgePrompt } from './prompts/judge.prompt.js';
16
+ import chalk from 'chalk';
17
+
18
+ /**
19
+ * CLI command: `copilot app eval <path-or-eval>`
20
+ *
21
+ * Evaluates a Fusion application using the GitHub Copilot SDK with
22
+ * `agent-browser` exposed as tools. The Copilot agent autonomously
23
+ * navigates, interacts, and judges the application against eval criteria.
24
+ */
25
+ const _appEvalCommand = createCommand('eval')
26
+ .description('Evaluate a Fusion app using Copilot SDK with agent-browser tools')
27
+ .argument('<path-or-eval>', 'Path to the Fusion application directory or an eval markdown file')
28
+ .option('--eval <name-or-path>', 'Specific eval to run (name or file path)')
29
+ .option('--port <port>', 'Port for the app server', '3333')
30
+ .option('--host <host>', 'Host for the app server', '0.0.0.0')
31
+ .option('--url <url>', 'Skip server start, use an already-running URL')
32
+ .option('--verbose', 'Show detailed output', false)
33
+ .option('--logon, --login', 'Open a headed browser for interactive MSAL login', false)
34
+ .option('-m, --model <model>', 'LLM model to use (e.g. claude-sonnet-4)')
35
+ .option('-r, --reasoning <effort>', 'Reasoning effort: low, medium, high, xhigh')
36
+ .option('-o, --output <dir>', 'Output directory for run artifacts')
37
+ .addHelpText(
38
+ 'after',
39
+ [
40
+ '',
41
+ 'Uses the GitHub Copilot SDK to create an agentic session where the LLM',
42
+ 'autonomously drives agent-browser to verify eval criteria.',
43
+ '',
44
+ 'Examples:',
45
+ ' $ ffc copilot app eval ./cookbooks/app-react',
46
+ ' $ ffc copilot app eval . --eval smoke',
47
+ ' $ ffc copilot app eval . --eval 06-people-api-page.md',
48
+ ' $ ffc copilot app eval ./cookbooks/app-react-router/eval/06-people-api-page.md',
49
+ ' $ ffc copilot app eval . --model claude-sonnet-4',
50
+ ' $ ffc copilot app eval . --login',
51
+ ' $ ffc copilot app eval . --logon',
52
+ ' $ ffc copilot app eval . --url http://localhost:3333/apps/my-app',
53
+ ].join('\n'),
54
+ )
55
+ .action(async (appOrEvalPath: string, options: CopilotEvalOptions) => {
56
+ const resolvedInput = resolveEvalCommandInput(appOrEvalPath);
57
+ const absAppPath = resolvedInput.appPath;
58
+ const port = parseInt(options.port, 10);
59
+ const verbose = options.verbose;
60
+
61
+ if (resolvedInput.evalFile && options.eval) {
62
+ console.error('❌ Do not combine a positional eval markdown file with --eval');
63
+ process.exit(1);
64
+ }
65
+
66
+ // ── Login mode: open headed browser for interactive MSAL login ──
67
+ if (options.login) {
68
+ await runLogin(absAppPath, port, options.host);
69
+ return;
70
+ }
71
+
72
+ // Resolve eval files
73
+ const evalFiles = resolvedInput.evalFile
74
+ ? [resolvedInput.evalFile]
75
+ : resolveEvalFiles(absAppPath, options.eval);
76
+ console.log(
77
+ `📋 Found ${evalFiles.length} eval(s): ${evalFiles.map((f) => basename(f)).join(', ')}`,
78
+ );
79
+
80
+ // Start the app dev server (unless --url provides an already-running URL)
81
+ const { serverProcess, appUrl } = await startAppServer(absAppPath, {
82
+ port,
83
+ host: options.host,
84
+ url: options.url,
85
+ verbose,
86
+ });
87
+
88
+ // Kill any stale daemon and clean up profile locks before the run
89
+ resetDaemon();
90
+
91
+ // Track active session logger + session for SIGINT cleanup
92
+ let logger: ReturnType<typeof attachSessionLogger> | null = null;
93
+ let activeSession: Awaited<ReturnType<typeof createSession>> | null = null;
94
+
95
+ process.on('SIGINT', () => {
96
+ logger?.stop();
97
+ console.log('\n\n👋 Shutting down...');
98
+ activeSession?.disconnect();
99
+ resetDaemon();
100
+ cleanup(serverProcess);
101
+ process.exit(130);
102
+ });
103
+
104
+ let failures = 0;
105
+ try {
106
+ for (const evalFilePath of evalFiles) {
107
+ const evalName = basename(evalFilePath, '.md');
108
+ const query = readFileSync(evalFilePath, 'utf-8').trim();
109
+ console.log(`\n${'═'.repeat(60)}`);
110
+ console.log(`📄 Running eval: ${evalName}`);
111
+ console.log(`${'═'.repeat(60)}`);
112
+
113
+ const runDir = createRunDir(evalName, options.output);
114
+
115
+ const ctx = { url: appUrl, outDir: runDir };
116
+
117
+ try {
118
+ const sessionConfig = {
119
+ model: options.model,
120
+ ...(options.reasoning ? { reasoningEffort: options.reasoning } : {}),
121
+ };
122
+
123
+ // Single session with all tools (browser + file)
124
+ const session = await createSession({ ctx, config: sessionConfig });
125
+ activeSession = session;
126
+ logger = attachSessionLogger(session, { requestedModel: options.model });
127
+
128
+ // ── Phase 1: Plan ──────────────────────────────────────────
129
+ await session.sendAndWait({ prompt: createPlanPrompt(query, ctx) }, 300_000);
130
+
131
+ const planPath = `${runDir}/plan.json`;
132
+ if (!existsSync(planPath)) {
133
+ console.error(
134
+ chalk.red(
135
+ `❌ Agent did not produce ${planPath}. The planning phase may have failed silently.`,
136
+ ),
137
+ );
138
+ session.disconnect();
139
+ logger.stop();
140
+ failures++;
141
+ continue;
142
+ }
143
+
144
+ const plan = JSON.parse(readFileSync(planPath, 'utf-8')) as Plan;
145
+ console.log(plan.summary);
146
+
147
+ // ── Phase 2: Execute + Evaluate each step ──────────────────
148
+ for (const step of plan.steps) {
149
+ console.log(chalk.cyan(`\n── Scenario: ${step.scenario}`));
150
+ for (const c of step.criteria) {
151
+ console.log(chalk.dim(` • ${c}`));
152
+ }
153
+ await session.sendAndWait({ prompt: createStepPrompt(step, ctx) }, 300_000);
154
+ }
155
+
156
+ // ── Phase 3: Judge ─────────────────────────────────────────
157
+ console.log(chalk.cyan('\n── Judging results...'));
158
+ await session.sendAndWait({ prompt: createJudgePrompt(ctx) }, 300_000);
159
+
160
+ session.disconnect();
161
+ logger.stop();
162
+
163
+ // Print the verdict
164
+ const verdictPath = `${runDir}/verdict.json`;
165
+ if (existsSync(verdictPath)) {
166
+ const verdict = JSON.parse(readFileSync(verdictPath, 'utf-8')) as Verdict;
167
+ console.log(formatVerdict(verdict));
168
+ if (!verdict.pass) failures++;
169
+ } else {
170
+ console.error(chalk.red(`❌ Judge did not produce ${verdictPath}.`));
171
+ failures++;
172
+ }
173
+ } catch (err) {
174
+ logger?.stop();
175
+ const msg = err instanceof Error ? err.message : String(err);
176
+ console.error(chalk.red(`\n❌ Eval "${evalName}" failed: ${msg}`));
177
+ if (err instanceof Error && err.stack) {
178
+ console.error(chalk.dim(err.stack));
179
+ }
180
+ failures++;
181
+ }
182
+ }
183
+ } finally {
184
+ logger?.stop();
185
+ resetDaemon();
186
+ cleanup(serverProcess);
187
+ process.exit(failures > 0 ? 1 : 0);
188
+ }
189
+ });
190
+
191
+ /** Intermediate `app` command group: `copilot app` */
192
+ const _appCommand = createCommand('app')
193
+ .description('Copilot-powered application commands')
194
+ .addCommand(_appEvalCommand);
195
+
196
+ /**
197
+ * Top-level `copilot` command group.
198
+ *
199
+ * Subcommands:
200
+ * app eval — Evaluate a Fusion app with Copilot SDK + agent-browser tools
201
+ */
202
+ export const copilotCommand = createCommand('copilot')
203
+ .description('GitHub Copilot SDK powered commands')
204
+ .addCommand(_appCommand);
@@ -0,0 +1,114 @@
1
+ import { mkdirSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+
4
+ import { ab, abErrorMessage } from '../../utils/index.js';
5
+ import { createAgentBrowserTools } from './tools/index.js';
6
+ import { createWriteFileTool, createAppendFileTool } from './tools/write-file.js';
7
+ import { createReadFileTool } from './tools/read-file.js';
8
+ import type { RuntimeExecutionContext } from './types.js';
9
+ import type { CopilotSession, SessionConfig } from '@github/copilot-sdk';
10
+ import { createSystemPrompt } from './prompts/system.prompt.js';
11
+ import { createDevServerContext } from './prompts/system.dev-server.prompt.js';
12
+
13
+ /**
14
+ * Lazily imports the Copilot SDK to avoid module resolution errors at CLI startup.
15
+ *
16
+ * The SDK depends on `vscode-jsonrpc/node` which may fail under certain Node versions
17
+ * when loaded eagerly via static imports.
18
+ *
19
+ * @returns The Copilot SDK module namespace
20
+ */
21
+ async function loadCopilotSdk(): Promise<typeof import('@github/copilot-sdk')> {
22
+ return await import('@github/copilot-sdk');
23
+ }
24
+
25
+ type SessionOptions = {
26
+ ctx: RuntimeExecutionContext;
27
+ defaultToolTimeoutMs?: number;
28
+ config?: Omit<
29
+ SessionConfig,
30
+ 'onPermissionRequest' | 'systemMessage' | 'availableTools' | 'excludedTools'
31
+ >;
32
+ };
33
+
34
+ export const createSession = async (options: SessionOptions): Promise<CopilotSession> => {
35
+ const { ctx, config, defaultToolTimeoutMs } = options;
36
+
37
+ const { CopilotClient, defineTool, approveAll } = await loadCopilotSdk();
38
+ const client = new CopilotClient({ autoStart: true });
39
+ await client.start();
40
+
41
+ const fileTools = [
42
+ createReadFileTool(ctx.outDir, defineTool),
43
+ createWriteFileTool(ctx.outDir, defineTool),
44
+ createAppendFileTool(ctx.outDir, defineTool),
45
+ ];
46
+
47
+ const tools = [
48
+ ...createBrowserTools(defineTool, {
49
+ cwd: ctx.outDir,
50
+ timeoutMs: defaultToolTimeoutMs,
51
+ }),
52
+ ...fileTools,
53
+ ...(config?.tools ?? []),
54
+ ];
55
+
56
+ // Exclude dangerous built-in tools; names vary across model providers
57
+ // so a blocklist is more robust than an allowlist.
58
+ const excludedTools = [
59
+ 'bash',
60
+ 'list_bash',
61
+ 'read_bash',
62
+ 'write_bash',
63
+ 'stop_bash',
64
+ 'sql',
65
+ 'web_fetch',
66
+ 'skill',
67
+ 'list_agents',
68
+ 'read_agent',
69
+ 'glob',
70
+ 'rg',
71
+ 'view',
72
+ 'task',
73
+ ];
74
+
75
+ const session = await client.createSession({
76
+ ...config,
77
+ tools,
78
+ excludedTools,
79
+ systemMessage: {
80
+ mode: 'replace',
81
+ content: `${createSystemPrompt(ctx)}\n\n## Dev Server${createDevServerContext(ctx)}`,
82
+ },
83
+ streaming: true,
84
+ onPermissionRequest: approveAll,
85
+ });
86
+
87
+ return session;
88
+ };
89
+
90
+ /**
91
+ * Creates the `agent-browser` tool definitions for the Copilot SDK session.
92
+ *
93
+ * Each tool wraps an `agent-browser` CLI subcommand so the Copilot agent can
94
+ * autonomously navigate and inspect a running Fusion application.
95
+ *
96
+ * @param defineTool - The `defineTool` helper from the Copilot SDK
97
+ * @returns Array of Copilot SDK tool definitions for browser interaction
98
+ */
99
+ function createBrowserTools(
100
+ defineTool: Awaited<ReturnType<typeof loadCopilotSdk>>['defineTool'],
101
+ options?: { cwd?: string; timeoutMs?: number },
102
+ ): ReturnType<typeof createAgentBrowserTools> {
103
+ const outDir = join(options?.cwd ?? process.cwd(), 'evidence');
104
+ mkdirSync(outDir, { recursive: true });
105
+ const invoke = (args: string[], timeoutMs = options?.timeoutMs): string => {
106
+ try {
107
+ return ab(args, timeoutMs);
108
+ } catch (err) {
109
+ const msg = abErrorMessage(err);
110
+ return `Error: ${msg}`;
111
+ }
112
+ };
113
+ return createAgentBrowserTools({ outDir, invoke }, defineTool);
114
+ }
@@ -0,0 +1,110 @@
1
+ import chalk from 'chalk';
2
+
3
+ import type { Plan, StepResult, Verdict } from './types.js';
4
+
5
+ // ── Plan ────────────────────────────────────────────────────────────────────
6
+
7
+ /** Renders a structured plan as a human-readable summary. */
8
+ export function formatPlan(plan: Plan): string {
9
+ const lines = [
10
+ '',
11
+ chalk.bold(`📋 ${plan.summary}`),
12
+ '',
13
+ ...plan.steps.flatMap((s, si) => [
14
+ ` ${chalk.dim(`${si + 1}.`)} ${s.scenario}`,
15
+ ...s.criteria.map((c) => chalk.dim(` • ${truncate(c, 70)}`)),
16
+ ]),
17
+ '',
18
+ ];
19
+ return lines.join('\n');
20
+ }
21
+
22
+ // ── Step result (execution line) ────────────────────────────────────────────
23
+
24
+ /** Renders a single step execution result. */
25
+ export function formatStepResult(result: StepResult): string {
26
+ const icon = statusIcon(result.ok);
27
+ const files = result.evidence.length ? chalk.dim(` [${result.evidence.join(', ')}]`) : '';
28
+ return ` ${icon} ${result.criterion}\n${chalk.dim(` ${result.note}`)}${files}`;
29
+ }
30
+
31
+ // ── Verdict ─────────────────────────────────────────────────────────────────
32
+
33
+ /** Renders the final verdict as a full-width coloured report. */
34
+ export function formatVerdict(verdict: Verdict): string {
35
+ const bar = '═'.repeat(60);
36
+ const banner = verdict.pass
37
+ ? chalk.bgGreen.black.bold(' ✅ PASS ')
38
+ : chalk.bgRed.white.bold(' ❌ FAIL ');
39
+
40
+ const lines = [
41
+ '',
42
+ bar,
43
+ ` ${banner}`,
44
+ bar,
45
+ '',
46
+ ...verdict.steps.map(
47
+ (s) => ` ${statusIcon(s.ok)} ${s.criterion}\n${chalk.dim(` ${s.note}`)}`,
48
+ ),
49
+ '',
50
+ chalk.dim(` ${verdict.reasoning}`),
51
+ '',
52
+ ];
53
+
54
+ if (verdict.ux && verdict.ux.length > 0) {
55
+ lines.push(chalk.bold(' 💡 UX Feedback'), '');
56
+ for (const hint of verdict.ux) {
57
+ lines.push(chalk.yellow(` • ${hint}`));
58
+ }
59
+ lines.push('');
60
+ }
61
+
62
+ return lines.join('\n');
63
+ }
64
+
65
+ // ── Dispatcher ──────────────────────────────────────────────────────────────
66
+
67
+ /**
68
+ * Attempts to detect the JSON shape of an assistant message and format it.
69
+ *
70
+ * Returns `null` when the content is not a recognised structure, so the
71
+ * caller can fall back to plain-text rendering.
72
+ */
73
+ export function tryFormatMessage(content: string): string | null {
74
+ let parsed: unknown;
75
+ try {
76
+ parsed = JSON.parse(content.trim());
77
+ } catch {
78
+ return null;
79
+ }
80
+ if (!parsed || typeof parsed !== 'object') return null;
81
+
82
+ // Verdict: { pass, reasoning, steps[] }
83
+ if ('pass' in parsed && 'steps' in parsed && Array.isArray((parsed as Verdict).steps)) {
84
+ return formatVerdict(parsed as Verdict);
85
+ }
86
+
87
+ // Plan: { summary, steps[] }
88
+ if ('summary' in parsed && 'steps' in parsed && Array.isArray((parsed as Plan).steps)) {
89
+ return formatPlan(parsed as Plan);
90
+ }
91
+
92
+ // Step result: { criterion, ok, note }
93
+ if ('criterion' in parsed && 'ok' in parsed && 'note' in parsed) {
94
+ return formatStepResult(parsed as StepResult);
95
+ }
96
+
97
+ return null;
98
+ }
99
+
100
+ // ── Helpers ─────────────────────────────────────────────────────────────────
101
+
102
+ function statusIcon(ok: boolean | string): string {
103
+ if (ok === true) return chalk.green('✔');
104
+ if (ok === false) return chalk.red('✖');
105
+ return chalk.yellow('⚠'); // "blocked", "flaky", etc.
106
+ }
107
+
108
+ function truncate(str: string, max: number): string {
109
+ return str.length > max ? `${str.slice(0, max - 1)}…` : str;
110
+ }
@@ -0,0 +1,92 @@
1
+ import { spawn, type ChildProcess } from 'node:child_process';
2
+ import { existsSync, readFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+
5
+ import {
6
+ ab,
7
+ resolveAppKey,
8
+ cleanup,
9
+ resetDaemon,
10
+ sleep,
11
+ stripAnsi,
12
+ waitForServer,
13
+ pollConsole,
14
+ } from '../../utils/index.js';
15
+
16
+ /**
17
+ * Interactive login mode for the `copilot app` command.
18
+ *
19
+ * Starts the application dev server, opens a headed browser window for
20
+ * manual MSAL authentication, and polls the browser console for the Fusion
21
+ * `"Done"` message that signals successful initialisation. The browser is
22
+ * left open after login so the user can finish any manual steps before
23
+ * pressing Ctrl+C.
24
+ *
25
+ * @param absAppPath - Absolute path to the Fusion application directory
26
+ * @param port - Port number for the dev server
27
+ * @param host - Host address for the dev server
28
+ */
29
+ export async function runLogin(absAppPath: string, port: number, host: string): Promise<void> {
30
+ const pkgJsonPath = join(absAppPath, 'package.json');
31
+ if (!existsSync(pkgJsonPath)) {
32
+ console.error(`❌ No package.json found at ${absAppPath}`);
33
+ process.exit(1);
34
+ }
35
+ const pkgJson = JSON.parse(readFileSync(pkgJsonPath, 'utf-8')) as { name?: string };
36
+ if (!pkgJson.name) {
37
+ console.error(`❌ package.json at ${absAppPath} has no "name" field`);
38
+ process.exit(1);
39
+ }
40
+
41
+ let serverProcess: ChildProcess | undefined;
42
+
43
+ console.log('📦 Resolving app manifest...');
44
+ const appKey = resolveAppKey(absAppPath);
45
+ console.log(`🚀 Starting server for ${appKey}...`);
46
+ serverProcess = spawn('ffc', ['app', 'dev', '--host', host, '--port', String(port)], {
47
+ cwd: absAppPath,
48
+ stdio: 'inherit',
49
+ detached: false,
50
+ });
51
+
52
+ const serverUrl = `http://localhost:${port}`;
53
+ const ready = await waitForServer(serverUrl, 60);
54
+ if (!ready) {
55
+ console.error('❌ Server failed to start within 60s');
56
+ cleanup(serverProcess);
57
+ process.exit(1);
58
+ }
59
+
60
+ const appUrl = `${serverUrl}/apps/${appKey}`;
61
+ console.log(`✅ Server ready at ${appUrl}`);
62
+ console.log('🌐 Opening headed browser — log in with your Equinor account...');
63
+
64
+ // Kill any stale daemon/Chrome so the new --headed --profile flags take effect
65
+ resetDaemon();
66
+ ab(['open', appUrl, '--headed'], 60_000);
67
+
68
+ // Ctrl+C handler: stop the dev server but leave the browser open
69
+ process.on('SIGINT', () => {
70
+ console.log('\n👋 Stopping server... (browser left open)');
71
+ cleanup(serverProcess);
72
+ process.exit(0);
73
+ });
74
+
75
+ // Wait for the Fusion app to initialise (browser console emits "✓ Done" after auth)
76
+ console.log('⏳ Waiting for app to initialize after login... (Ctrl+C to finish early)');
77
+ const initialized = await pollConsole(
78
+ (logs: string) => stripAnsi(logs).includes('Done'),
79
+ 300_000,
80
+ );
81
+
82
+ if (initialized) {
83
+ await sleep(2000);
84
+ console.log('✅ Login complete — session saved.');
85
+ } else {
86
+ console.log('⚠ Timed out waiting for app init. Session may still be saved.');
87
+ }
88
+
89
+ // Always wait for manual Ctrl+C so the user can finish what they're doing
90
+ console.log('🌐 Browser still open. Press Ctrl+C to exit.');
91
+ await new Promise(() => {}); // block until SIGINT
92
+ }
@@ -0,0 +1,48 @@
1
+ import type { RuntimeExecutionContext } from '../types.js';
2
+
3
+ /**
4
+ * Creates the execution prompt for a single test step.
5
+ *
6
+ * The agent receives one plan step and executes its actions against the
7
+ * running application using browser tools, collecting evidence artifacts
8
+ * into the evidence directory.
9
+ *
10
+ * @param actions - The action strings to execute in order
11
+ * @param ctx - Runtime context with output directory and app URL
12
+ * @returns The fully interpolated execution prompt string
13
+ */
14
+ export const createExecuteStepPrompt = (
15
+ actions: string[],
16
+ ctx: RuntimeExecutionContext,
17
+ ): string => {
18
+ return `
19
+ You are now EXECUTING one test step using real browser tools.
20
+
21
+ ACTIONS: ${JSON.stringify(actions, null, 2)}
22
+
23
+ APPLICATION URL: ${ctx.url}
24
+ EVIDENCE DIRECTORY: ${ctx.outDir}/evidence
25
+
26
+ Execute the "actions" array in order using the available browser tool calls.
27
+ Collect rich evidence at every stage — especially on failure.
28
+
29
+ Evidence rules (mandatory):
30
+ - Before important actions: screenshot --annotate evidence/step-{criterion-slug}-before.png
31
+ - After the step (always): screenshot --full --annotate evidence/step-{criterion-slug}-after.png
32
+ - On any failure or suspicion:
33
+ errors > evidence/step-{criterion-slug}-errors.log
34
+ console > evidence/step-{criterion-slug}-console.log
35
+ snapshot -i --json > evidence/step-{criterion-slug}-snapshot.json
36
+ - Use descriptive, unique filenames
37
+ Examples: step-login-button-missing-screenshot.png, step-dashboard-url.txt
38
+
39
+ Important patterns:
40
+ - Start most steps with: snapshot -i --annotate → use @eN refs for element stability
41
+ - After navigation: wait --load networkidle or wait --url "**/pattern*"
42
+ - On failure (non-zero exit, timeout, missing element, JS error): capture extra evidence
43
+ - If flaky (timing, network): take 2–3 extra screenshots ~1 s apart
44
+
45
+ When done, your work will be evaluated against the step's passEvidence and failEvidence.
46
+ Do NOT output markdown or explanations — only use browser tool calls and collect evidence.
47
+ `.trim();
48
+ };
@@ -0,0 +1,121 @@
1
+ import { readdirSync, readFileSync, existsSync } from 'node:fs';
2
+ import { join, extname } from 'node:path';
3
+ import type { RuntimeExecutionContext } from '../types.js';
4
+
5
+ /** Text file extensions that are safe to embed in the prompt. */
6
+ const TEXT_EXTENSIONS = new Set(['.txt', '.json', '.jsonl', '.log', '.html', '.csv']);
7
+
8
+ /**
9
+ * Reads `executions.jsonl` and all text evidence files from the run directory
10
+ * and returns them as a single string block suitable for prompt embedding.
11
+ *
12
+ * Binary files (screenshots) are listed by name but not inlined.
13
+ *
14
+ * @param outDir - The run output directory containing executions.jsonl and evidence/
15
+ * @returns Formatted string with execution records and text evidence
16
+ */
17
+ function collectEvidence(outDir: string): string {
18
+ const parts: string[] = [];
19
+
20
+ // ── executions.jsonl ──
21
+ const execPath = join(outDir, 'executions.jsonl');
22
+ if (existsSync(execPath)) {
23
+ parts.push('### executions.jsonl', readFileSync(execPath, 'utf-8').trim());
24
+ } else {
25
+ parts.push('### executions.jsonl', '(file not found)');
26
+ }
27
+
28
+ // ── evidence/ directory ──
29
+ const evidenceDir = join(outDir, 'evidence');
30
+ if (existsSync(evidenceDir)) {
31
+ const files = readdirSync(evidenceDir).sort();
32
+ for (const file of files) {
33
+ const ext = extname(file).toLowerCase();
34
+ if (TEXT_EXTENSIONS.has(ext)) {
35
+ const content = readFileSync(join(evidenceDir, file), 'utf-8').trim();
36
+ parts.push(`### evidence/${file}`, content);
37
+ } else {
38
+ parts.push(`### evidence/${file}`, `(binary file — ${ext} screenshot)`);
39
+ }
40
+ }
41
+ }
42
+
43
+ return parts.join('\n\n');
44
+ }
45
+
46
+ /**
47
+ * Creates the final-verdict prompt for the judge phase.
48
+ *
49
+ * The judge reviews ALL step executions and evidence holistically, then writes
50
+ * a single `verdict.json` with an overall pass/fail decision. This differs
51
+ * from the per-step evaluate prompt which assesses one criterion at a time.
52
+ *
53
+ * Execution records and text evidence files are embedded directly in the prompt
54
+ * so the model does not need file-reading tools or SDK attachment resolution.
55
+ *
56
+ * @param ctx - Runtime context containing the output directory and app URL
57
+ * @returns The fully interpolated judge prompt string
58
+ */
59
+ export const createJudgePrompt = (ctx: RuntimeExecutionContext): string => {
60
+ const evidence = collectEvidence(ctx.outDir);
61
+
62
+ return `
63
+ All test steps have been executed. You are now the JUDGE.
64
+
65
+ Review the evidence below and any screenshots using read_file, then produce a final verdict.
66
+
67
+ <evidence>
68
+ ${evidence}
69
+ </evidence>
70
+
71
+ To inspect screenshots, use read_file with paths like "evidence/step-hello-fusion-header.jpg".
72
+ Images are returned as inline vision content you can see.
73
+
74
+ Save your verdict using write_file with path "verdict.json".
75
+ You MUST call write_file. Do NOT output the JSON as a message.
76
+
77
+ Evaluation rules — apply exactly:
78
+ 1. Auto-fail a criterion if:
79
+ - Any "Uncaught", TypeError, or ReferenceError appears in console/errors during that step
80
+ - The page shows an error, HTTP 500, white screen, or spinner lasting >15 s
81
+ - The expected element, text, or URL never appeared
82
+ - An explicit assertion (is visible, get text, get count) returned a failing result
83
+ 2. A criterion passes only when:
84
+ - All passEvidence conditions are clearly confirmed in screenshots (use read_file to view them), console, or URL
85
+ - No critical functional deviation is present
86
+ 3. Be lenient on:
87
+ - Console warnings, deprecations, analytics 404s
88
+ - Cosmetic shifts <5 px or layout differences that do not hide content
89
+ - Minor visual differences without functional impact
90
+
91
+ Overall "pass" = true ONLY if every criterion is "ok": true.
92
+
93
+ Output ONLY this JSON — no markdown fences, no prose:
94
+
95
+ {
96
+ "pass": boolean,
97
+ "reasoning": "One-paragraph summary explaining the overall pass/fail outcome",
98
+ "steps": [
99
+ {
100
+ "criterion": "...",
101
+ "ok": true | false | "blocked" | "flaky",
102
+ "note": "Evidence-based verdict for this criterion"
103
+ }
104
+ ],
105
+ "ux": [
106
+ "Short UX observation or improvement suggestion based on what you saw in the screenshots and evidence"
107
+ ]
108
+ }
109
+
110
+ The "ux" array is for observations about the user experience that go beyond
111
+ pass/fail — things a product owner or designer would want to know:
112
+ - Accessibility issues (contrast, missing labels, keyboard traps)
113
+ - Loading performance (slow renders, layout shifts, flash of unstyled content)
114
+ - Visual polish (alignment, spacing, truncation, responsive issues)
115
+ - Interaction hints (unclear affordances, missing hover states, confusing flow)
116
+ - Content quality (typos, unclear copy, missing help text)
117
+ Only include genuine observations from the evidence. Skip "ux" if nothing stands out.
118
+
119
+ If evidence for a criterion is missing or ambiguous, fail it.
120
+ `.trim();
121
+ };