norn-cli 1.4.0 → 1.4.2

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.
@@ -0,0 +1,722 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.registerChatParticipant = registerChatParticipant;
37
+ const vscode = __importStar(require("vscode"));
38
+ const path = __importStar(require("path"));
39
+ const nornPrompt_1 = require("./nornPrompt");
40
+ const swaggerParser_1 = require("./swaggerParser");
41
+ const sequenceRunner_1 = require("./sequenceRunner");
42
+ const parser_1 = require("./parser");
43
+ const environmentProvider_1 = require("./environmentProvider");
44
+ const httpClient_1 = require("./httpClient");
45
+ // ---------------------------------------------------------------------------
46
+ // Constants
47
+ // ---------------------------------------------------------------------------
48
+ /** Maximum character budget for file contents sent as LLM context. */
49
+ const MAX_CONTEXT_CHARS = 30_000;
50
+ // ---------------------------------------------------------------------------
51
+ // Tool definitions — inline tools the LLM can call to edit/create files
52
+ // ---------------------------------------------------------------------------
53
+ const NORN_TOOLS = [
54
+ {
55
+ name: 'norn_applyEdit',
56
+ description: 'Apply an edit to the user\'s currently open Norn file (.norn, .nornapi, or .nornenv). ' +
57
+ 'Can replace specific text, insert at a line, or append to the end of the file.',
58
+ inputSchema: {
59
+ type: 'object',
60
+ properties: {
61
+ action: {
62
+ type: 'string',
63
+ enum: ['replace', 'insert', 'append'],
64
+ description: 'The type of edit: "replace" finds and replaces text, "insert" adds before a line number, "append" adds to end of file',
65
+ },
66
+ content: {
67
+ type: 'string',
68
+ description: 'The new content to insert, append, or use as the replacement',
69
+ },
70
+ searchText: {
71
+ type: 'string',
72
+ description: 'For "replace" action only: the exact text to find in the file and replace with content',
73
+ },
74
+ line: {
75
+ type: 'number',
76
+ description: 'For "insert" action only: the 1-based line number to insert before',
77
+ },
78
+ },
79
+ required: ['action', 'content'],
80
+ },
81
+ },
82
+ {
83
+ name: 'norn_createFile',
84
+ description: 'Create a new Norn file (.norn, .nornapi, or .nornenv) in the same directory as the currently open file, or in the workspace root.',
85
+ inputSchema: {
86
+ type: 'object',
87
+ properties: {
88
+ filename: {
89
+ type: 'string',
90
+ description: 'The filename to create (e.g., "my-test.norn", "api.nornapi", ".nornenv"). Relative to the current file\'s directory.',
91
+ },
92
+ content: {
93
+ type: 'string',
94
+ description: 'The full content for the new file',
95
+ },
96
+ },
97
+ required: ['filename', 'content'],
98
+ },
99
+ },
100
+ {
101
+ name: 'norn_readWorkspaceFile',
102
+ description: 'Read the contents of a Norn file (.norn, .nornapi, or .nornenv) from the workspace. ' +
103
+ 'Use this to inspect existing tests, API definitions, or environment configurations.',
104
+ inputSchema: {
105
+ type: 'object',
106
+ properties: {
107
+ pattern: {
108
+ type: 'string',
109
+ description: 'A filename or glob pattern to search for (e.g., "login.norn", "*.nornapi", "shared/*.norn"). Relative to workspace root.',
110
+ },
111
+ },
112
+ required: ['pattern'],
113
+ },
114
+ },
115
+ {
116
+ name: 'norn_getDiagnostics',
117
+ description: 'Get the current diagnostics (errors and warnings) for Norn files in the workspace. ' +
118
+ 'Use this to understand what problems exist so you can help fix them.',
119
+ inputSchema: {
120
+ type: 'object',
121
+ properties: {
122
+ filePath: {
123
+ type: 'string',
124
+ description: 'Optional: a specific file path to get diagnostics for. If omitted, returns diagnostics for all open Norn files.',
125
+ },
126
+ },
127
+ required: [],
128
+ },
129
+ },
130
+ {
131
+ name: 'norn_readSwagger',
132
+ description: 'Fetch and parse an OpenAPI/Swagger specification from a URL. Returns the API title, version, base URL, and a list of endpoints grouped by section/tag. ' +
133
+ 'Use this to understand an API before generating tests.',
134
+ inputSchema: {
135
+ type: 'object',
136
+ properties: {
137
+ url: {
138
+ type: 'string',
139
+ description: 'The URL of the Swagger/OpenAPI JSON spec (e.g., "https://petstore.swagger.io/v2/swagger.json")',
140
+ },
141
+ },
142
+ required: ['url'],
143
+ },
144
+ },
145
+ {
146
+ name: 'norn_runTest',
147
+ description: 'Run a Norn test sequence from a file in the workspace and return the results. ' +
148
+ 'Use this to verify that a test passes, check assertion results, or debug failures. ' +
149
+ 'IMPORTANT: If the .nornenv has environment-specific variables (under [env:name] sections), ' +
150
+ 'you MUST pass the "environment" parameter with the exact env name (e.g., "dev", "prod") ' +
151
+ 'for those variables to resolve. Each call is independent — switching environments between runs requires passing the env name each time.',
152
+ inputSchema: {
153
+ type: 'object',
154
+ properties: {
155
+ filePath: {
156
+ type: 'string',
157
+ description: 'Path to the .norn file containing the test sequence. Relative to workspace root.',
158
+ },
159
+ sequenceName: {
160
+ type: 'string',
161
+ description: 'Optional: the name of a specific sequence to run. If omitted, runs the first test sequence found.',
162
+ },
163
+ environment: {
164
+ type: 'string',
165
+ description: 'The environment name from the .nornenv file to use (e.g., "dev", "prod", "staging"). ' +
166
+ 'Pass this to ensure environment-specific variables resolve correctly. ' +
167
+ 'If omitted, auto-selects the first available environment.',
168
+ },
169
+ },
170
+ required: ['filePath'],
171
+ },
172
+ },
173
+ ];
174
+ // ---------------------------------------------------------------------------
175
+ // Registration
176
+ // ---------------------------------------------------------------------------
177
+ /**
178
+ * Registers the @norn Copilot Chat participant.
179
+ *
180
+ * Gracefully handles the case where GitHub Copilot is not installed —
181
+ * the rest of the extension continues to work normally.
182
+ */
183
+ function registerChatParticipant(context) {
184
+ try {
185
+ if (!vscode.chat?.createChatParticipant) {
186
+ // vscode.chat API not available (Copilot not installed or old VS Code)
187
+ return;
188
+ }
189
+ const participant = vscode.chat.createChatParticipant('norn.chat', nornChatHandler);
190
+ participant.iconPath = vscode.Uri.joinPath(context.extensionUri, 'images', 'icon.png');
191
+ participant.followupProvider = {
192
+ provideFollowups(result, _context, _token) {
193
+ const meta = result.metadata;
194
+ if (meta?.command === 'generate') {
195
+ return [
196
+ { prompt: 'Add assertions for the response', label: 'Add assertions' },
197
+ { prompt: 'Make this a parameterized test with @data', label: 'Parameterize' },
198
+ ];
199
+ }
200
+ if (meta?.command === 'explain') {
201
+ return [
202
+ { prompt: 'How can I improve this test?', label: 'Improve test' },
203
+ { prompt: 'What assertions should I add?', label: 'Suggest assertions' },
204
+ ];
205
+ }
206
+ // General follow-ups
207
+ return [
208
+ { prompt: 'Show me how to write assertions', label: 'Assertion examples' },
209
+ { prompt: 'How do I set up environments?', label: 'Environment setup' },
210
+ { prompt: 'How do I run tests in CI/CD?', label: 'CLI / CI usage' },
211
+ ];
212
+ }
213
+ };
214
+ context.subscriptions.push(participant);
215
+ }
216
+ catch {
217
+ // Chat API not available — silently continue
218
+ console.log('Norn: Chat participant not registered (GitHub Copilot may not be installed)');
219
+ }
220
+ }
221
+ // ---------------------------------------------------------------------------
222
+ // Handler
223
+ // ---------------------------------------------------------------------------
224
+ const MAX_TOOL_ROUNDS = 5; // safety limit to prevent infinite tool-call loops
225
+ const nornChatHandler = async (request, context, stream, token) => {
226
+ const command = request.command; // 'generate' | 'explain' | 'convert' | undefined
227
+ // Build the prompt messages
228
+ const messages = [];
229
+ // 1. Norn system-level grounding (sent as User role — LM API has no System role)
230
+ messages.push(vscode.LanguageModelChatMessage.User(nornPrompt_1.NORN_SYSTEM_PROMPT));
231
+ // 2. Conversation history — include prior turns for multi-turn context
232
+ for (const turn of context.history) {
233
+ if (turn instanceof vscode.ChatRequestTurn) {
234
+ messages.push(vscode.LanguageModelChatMessage.User(turn.prompt));
235
+ }
236
+ else if (turn instanceof vscode.ChatResponseTurn) {
237
+ const text = turn.response
238
+ .map(part => (part instanceof vscode.ChatResponseMarkdownPart ? part.value.value : ''))
239
+ .join('');
240
+ if (text) {
241
+ messages.push(vscode.LanguageModelChatMessage.Assistant(text));
242
+ }
243
+ }
244
+ }
245
+ // 3. Active editor context — include the user's current file if it's a Norn file
246
+ const activeFileContext = getActiveNornFileContext();
247
+ if (activeFileContext) {
248
+ messages.push(vscode.LanguageModelChatMessage.User(activeFileContext.prompt));
249
+ stream.reference(activeFileContext.uri);
250
+ }
251
+ // 4. Build the user prompt based on the slash command
252
+ const userPrompt = buildUserPrompt(command, request.prompt);
253
+ messages.push(vscode.LanguageModelChatMessage.User(userPrompt));
254
+ // Show progress
255
+ stream.progress('Thinking...');
256
+ // Request options — include tools so the LLM can edit/create files
257
+ const requestOptions = {
258
+ tools: NORN_TOOLS,
259
+ };
260
+ // Tool-calling loop: the LLM may request tool calls, we execute them and feed results back
261
+ try {
262
+ for (let round = 0; round < MAX_TOOL_ROUNDS; round++) {
263
+ if (token.isCancellationRequested) {
264
+ break;
265
+ }
266
+ const chatResponse = await request.model.sendRequest(messages, requestOptions, token);
267
+ // Collect text and tool calls from the response stream
268
+ const toolCalls = [];
269
+ let hasText = false;
270
+ for await (const part of chatResponse.stream) {
271
+ if (token.isCancellationRequested) {
272
+ break;
273
+ }
274
+ if (part instanceof vscode.LanguageModelTextPart) {
275
+ stream.markdown(part.value);
276
+ hasText = true;
277
+ }
278
+ else if (part instanceof vscode.LanguageModelToolCallPart) {
279
+ toolCalls.push(part);
280
+ }
281
+ }
282
+ // If no tool calls or cancelled, we're done
283
+ if (toolCalls.length === 0 || token.isCancellationRequested) {
284
+ break;
285
+ }
286
+ // Add assistant message with the tool calls to conversation
287
+ messages.push(vscode.LanguageModelChatMessage.Assistant(toolCalls));
288
+ // Execute each tool call and feed results back
289
+ for (const toolCall of toolCalls) {
290
+ if (token.isCancellationRequested) {
291
+ break;
292
+ }
293
+ const result = await executeNornTool(toolCall, stream);
294
+ messages.push(vscode.LanguageModelChatMessage.User([
295
+ new vscode.LanguageModelToolResultPart(toolCall.callId, [
296
+ new vscode.LanguageModelTextPart(result),
297
+ ]),
298
+ ]));
299
+ }
300
+ }
301
+ }
302
+ catch (err) {
303
+ handleLlmError(err, stream);
304
+ }
305
+ return { metadata: { command } };
306
+ };
307
+ async function executeNornTool(toolCall, stream) {
308
+ switch (toolCall.name) {
309
+ case 'norn_applyEdit':
310
+ return executeApplyEdit(toolCall.input, stream);
311
+ case 'norn_createFile':
312
+ return executeCreateFile(toolCall.input, stream);
313
+ case 'norn_readWorkspaceFile':
314
+ return executeReadWorkspaceFile(toolCall.input, stream);
315
+ case 'norn_getDiagnostics':
316
+ return executeGetDiagnostics(toolCall.input, stream);
317
+ case 'norn_readSwagger':
318
+ return executeReadSwagger(toolCall.input, stream);
319
+ case 'norn_runTest':
320
+ return executeRunTest(toolCall.input, stream);
321
+ default:
322
+ return `Unknown tool: ${toolCall.name}`;
323
+ }
324
+ }
325
+ async function executeApplyEdit(input, stream) {
326
+ const editor = vscode.window.activeTextEditor;
327
+ if (!editor) {
328
+ return 'Error: No active editor. Please open a .norn, .nornapi, or .nornenv file first.';
329
+ }
330
+ const doc = editor.document;
331
+ const lang = doc.languageId;
332
+ if (lang !== 'norn' && lang !== 'nornapi' && lang !== 'nornenv') {
333
+ return `Error: Active file is not a Norn file (it's "${lang}"). Please open a .norn, .nornapi, or .nornenv file.`;
334
+ }
335
+ const edit = new vscode.WorkspaceEdit();
336
+ switch (input.action) {
337
+ case 'replace': {
338
+ if (!input.searchText) {
339
+ return 'Error: "replace" action requires "searchText" to find the text to replace.';
340
+ }
341
+ const text = doc.getText();
342
+ const idx = text.indexOf(input.searchText);
343
+ if (idx === -1) {
344
+ return `Error: Could not find the text to replace. The searchText was not found in the file.`;
345
+ }
346
+ // Warn if the searchText matches more than one location
347
+ const secondIdx = text.indexOf(input.searchText, idx + 1);
348
+ if (secondIdx !== -1) {
349
+ return `Error: The searchText was found multiple times in the file. Please provide more specific text that uniquely identifies the location to replace.`;
350
+ }
351
+ const startPos = doc.positionAt(idx);
352
+ const endPos = doc.positionAt(idx + input.searchText.length);
353
+ edit.replace(doc.uri, new vscode.Range(startPos, endPos), input.content);
354
+ break;
355
+ }
356
+ case 'insert': {
357
+ const lineNum = (input.line ?? 1) - 1; // convert to 0-based
358
+ const clampedLine = Math.max(0, Math.min(lineNum, doc.lineCount));
359
+ const position = new vscode.Position(clampedLine, 0);
360
+ const contentWithNewline = input.content.endsWith('\n') ? input.content : input.content + '\n';
361
+ edit.insert(doc.uri, position, contentWithNewline);
362
+ break;
363
+ }
364
+ case 'append': {
365
+ const lastLine = doc.lineCount - 1;
366
+ const lastChar = doc.lineAt(lastLine).text.length;
367
+ const position = new vscode.Position(lastLine, lastChar);
368
+ const prefix = lastChar > 0 ? '\n' : '';
369
+ edit.insert(doc.uri, position, prefix + input.content);
370
+ break;
371
+ }
372
+ default:
373
+ return `Error: Unknown action "${input.action}". Use "replace", "insert", or "append".`;
374
+ }
375
+ const success = await vscode.workspace.applyEdit(edit);
376
+ if (!success) {
377
+ return 'Error: Failed to apply the edit. The file may be read-only.';
378
+ }
379
+ stream.progress('Applied edit to ' + path.basename(doc.uri.fsPath));
380
+ return `Successfully applied ${input.action} edit to ${path.basename(doc.uri.fsPath)}.`;
381
+ }
382
+ async function executeCreateFile(input, stream) {
383
+ // Determine the directory: use the active file's directory, or workspace root
384
+ let targetDir;
385
+ const activeEditor = vscode.window.activeTextEditor;
386
+ if (activeEditor) {
387
+ targetDir = vscode.Uri.file(path.dirname(activeEditor.document.uri.fsPath));
388
+ }
389
+ else {
390
+ const workspaceFolders = vscode.workspace.workspaceFolders;
391
+ if (!workspaceFolders || workspaceFolders.length === 0) {
392
+ return 'Error: No workspace folder open and no active file. Cannot determine where to create the file.';
393
+ }
394
+ targetDir = workspaceFolders[0].uri;
395
+ }
396
+ const fileUri = vscode.Uri.joinPath(targetDir, input.filename);
397
+ // Check if file already exists
398
+ try {
399
+ await vscode.workspace.fs.stat(fileUri);
400
+ return `Error: File "${input.filename}" already exists at ${fileUri.fsPath}. Use norn_applyEdit to modify existing files.`;
401
+ }
402
+ catch {
403
+ // File doesn't exist — good, we can create it
404
+ }
405
+ await vscode.workspace.fs.writeFile(fileUri, Buffer.from(input.content, 'utf8'));
406
+ // Open the newly created file
407
+ const doc = await vscode.workspace.openTextDocument(fileUri);
408
+ await vscode.window.showTextDocument(doc, { preview: false });
409
+ stream.progress('Created ' + input.filename);
410
+ return `Successfully created file "${input.filename}" at ${fileUri.fsPath} and opened it in the editor.`;
411
+ }
412
+ async function executeReadWorkspaceFile(input, stream) {
413
+ const workspaceFolders = vscode.workspace.workspaceFolders;
414
+ if (!workspaceFolders || workspaceFolders.length === 0) {
415
+ return 'Error: No workspace folder open.';
416
+ }
417
+ // Search for files matching the pattern — normalize to avoid double `**/` prefixes
418
+ const rawPattern = input.pattern.replace(/^\/+/, ''); // strip leading slashes
419
+ const pattern = rawPattern.startsWith('**/') ? rawPattern : `**/${rawPattern}`;
420
+ const files = await vscode.workspace.findFiles(pattern, '**/node_modules/**', 10);
421
+ if (files.length === 0) {
422
+ return `No files found matching pattern "${pattern}". Try a different pattern like "*.norn" or "tests/**/*.norn".`;
423
+ }
424
+ const results = [];
425
+ let totalSize = 0;
426
+ for (const fileUri of files) {
427
+ if (totalSize >= MAX_CONTEXT_CHARS) {
428
+ results.push(`\n... (${files.length - results.length} more files truncated to stay within size limits)`);
429
+ break;
430
+ }
431
+ const bytes = await vscode.workspace.fs.readFile(fileUri);
432
+ const content = Buffer.from(bytes).toString('utf8');
433
+ const relativePath = vscode.workspace.asRelativePath(fileUri);
434
+ if (totalSize + content.length > MAX_CONTEXT_CHARS) {
435
+ const remaining = MAX_CONTEXT_CHARS - totalSize;
436
+ results.push(`--- ${relativePath} (truncated) ---\n${content.substring(0, remaining)}\n...`);
437
+ totalSize = MAX_CONTEXT_CHARS;
438
+ }
439
+ else {
440
+ results.push(`--- ${relativePath} ---\n${content}`);
441
+ totalSize += content.length;
442
+ }
443
+ }
444
+ stream.progress(`Read ${Math.min(files.length, results.length)} file(s)`);
445
+ return results.join('\n\n');
446
+ }
447
+ async function executeGetDiagnostics(input, _stream) {
448
+ const allDiagnostics = vscode.languages.getDiagnostics();
449
+ const nornExtensions = ['.norn', '.nornapi', '.nornenv'];
450
+ const relevantDiagnostics = [];
451
+ for (const [uri, diagnostics] of allDiagnostics) {
452
+ const filePath = uri.fsPath;
453
+ const isNornFile = nornExtensions.some(ext => filePath.endsWith(ext));
454
+ if (!isNornFile) {
455
+ continue;
456
+ }
457
+ // If a specific file was requested, filter to just that file
458
+ if (input.filePath && !filePath.endsWith(input.filePath) && !filePath.includes(input.filePath)) {
459
+ continue;
460
+ }
461
+ if (diagnostics.length > 0) {
462
+ relevantDiagnostics.push({
463
+ file: vscode.workspace.asRelativePath(uri),
464
+ diagnostics,
465
+ });
466
+ }
467
+ }
468
+ if (relevantDiagnostics.length === 0) {
469
+ return input.filePath
470
+ ? `No diagnostics found for "${input.filePath}". The file may have no errors or warnings.`
471
+ : 'No diagnostics found for any Norn files. Everything looks clean!';
472
+ }
473
+ const lines = [];
474
+ for (const { file, diagnostics } of relevantDiagnostics) {
475
+ lines.push(`## ${file}`);
476
+ for (const d of diagnostics) {
477
+ const severity = d.severity === vscode.DiagnosticSeverity.Error ? 'ERROR'
478
+ : d.severity === vscode.DiagnosticSeverity.Warning ? 'WARNING'
479
+ : 'INFO';
480
+ lines.push(` Line ${d.range.start.line + 1}: [${severity}] ${d.message}`);
481
+ }
482
+ }
483
+ return lines.join('\n');
484
+ }
485
+ async function executeReadSwagger(input, stream) {
486
+ try {
487
+ stream.progress('Fetching Swagger spec...');
488
+ const spec = await (0, swaggerParser_1.parseSwaggerSpec)(input.url);
489
+ const lines = [
490
+ `# ${spec.title} v${spec.version}`,
491
+ `Base URL: ${spec.baseUrl}`,
492
+ '',
493
+ ];
494
+ for (const section of spec.sections) {
495
+ lines.push(`## ${section.name}`);
496
+ if (section.description) {
497
+ lines.push(section.description);
498
+ }
499
+ for (const endpoint of section.endpoints) {
500
+ const params = endpoint.parameters.length > 0
501
+ ? ` (params: ${endpoint.parameters.join(', ')})`
502
+ : '';
503
+ lines.push(` ${endpoint.method.toUpperCase()} ${endpoint.path}${params}${endpoint.name ? ` — ${endpoint.name}` : ''}`);
504
+ }
505
+ lines.push('');
506
+ }
507
+ stream.progress('Parsed Swagger spec');
508
+ return lines.join('\n');
509
+ }
510
+ catch (err) {
511
+ const message = err instanceof Error ? err.message : String(err);
512
+ return `Error fetching Swagger spec from "${input.url}": ${message}`;
513
+ }
514
+ }
515
+ async function executeRunTest(input, stream) {
516
+ const workspaceFolders = vscode.workspace.workspaceFolders;
517
+ if (!workspaceFolders || workspaceFolders.length === 0) {
518
+ return 'Error: No workspace folder open.';
519
+ }
520
+ // Resolve the file path
521
+ const workspaceRoot = workspaceFolders[0].uri.fsPath;
522
+ const absolutePath = path.isAbsolute(input.filePath)
523
+ ? input.filePath
524
+ : path.resolve(workspaceRoot, input.filePath);
525
+ // Read the file
526
+ let fileContent;
527
+ try {
528
+ const uri = vscode.Uri.file(absolutePath);
529
+ const bytes = await vscode.workspace.fs.readFile(uri);
530
+ fileContent = Buffer.from(bytes).toString('utf8');
531
+ }
532
+ catch {
533
+ return `Error: Could not read file "${input.filePath}". Make sure the path is correct.`;
534
+ }
535
+ // Extract sequences
536
+ const sequences = (0, sequenceRunner_1.extractSequences)(fileContent);
537
+ if (sequences.length === 0) {
538
+ return `No sequences found in "${input.filePath}". Make sure the file contains a "sequence" or "test sequence" block.`;
539
+ }
540
+ // Find the target sequence
541
+ let target;
542
+ if (input.sequenceName) {
543
+ const found = sequences.find(s => s.name.toLowerCase() === input.sequenceName.toLowerCase());
544
+ if (!found) {
545
+ const available = sequences.map(s => s.name).join(', ');
546
+ return `Sequence "${input.sequenceName}" not found. Available sequences: ${available}`;
547
+ }
548
+ target = found;
549
+ }
550
+ else {
551
+ // Pick the first test sequence, or first sequence if none are tests
552
+ target = sequences.find(s => s.isTest) ?? sequences[0];
553
+ }
554
+ // Gather variables and environment
555
+ const fileDir = path.dirname(absolutePath);
556
+ // Ensure an environment is active so env-specific variables (e.g. baseUrl) resolve.
557
+ // Priority: explicit input.environment > already-active env > first available env.
558
+ const previousEnv = (0, environmentProvider_1.getActiveEnvironment)();
559
+ const effectiveEnv = input.environment
560
+ ?? previousEnv
561
+ ?? (0, environmentProvider_1.getAvailableEnvironments)(absolutePath)[0]; // auto-pick first if nothing is set
562
+ if (effectiveEnv && effectiveEnv !== previousEnv) {
563
+ (0, environmentProvider_1.setActiveEnvironment)(effectiveEnv);
564
+ }
565
+ const envVars = (0, environmentProvider_1.getEnvironmentVariables)(absolutePath);
566
+ const fileVars = (0, parser_1.extractVariables)(fileContent);
567
+ const allVariables = { ...envVars, ...fileVars };
568
+ // Resolve imports for header groups and endpoints
569
+ const importResult = await (0, parser_1.resolveImports)(fileContent, fileDir, async (p) => {
570
+ const bytes = await vscode.workspace.fs.readFile(vscode.Uri.file(p));
571
+ return Buffer.from(bytes).toString('utf8');
572
+ });
573
+ // Combine imported variables
574
+ const importedVars = (0, parser_1.extractVariables)(importResult.importedContent);
575
+ Object.assign(allVariables, importedVars);
576
+ const apiDefinitions = importResult.headerGroups.length > 0 || importResult.endpoints.length > 0
577
+ ? { headerGroups: importResult.headerGroups, endpoints: importResult.endpoints }
578
+ : undefined;
579
+ const cookieJar = (0, httpClient_1.createCookieJar)();
580
+ // Restore original environment after gathering variables
581
+ if (effectiveEnv !== previousEnv) {
582
+ (0, environmentProvider_1.setActiveEnvironment)(previousEnv);
583
+ }
584
+ // Run the sequence
585
+ // Report which environment is active for transparency
586
+ const availableEnvs = (0, environmentProvider_1.getAvailableEnvironments)(absolutePath);
587
+ stream.progress(`Running "${target.name}" with env: ${effectiveEnv ?? 'none'}...`);
588
+ try {
589
+ const result = await (0, sequenceRunner_1.runSequenceWithJar)(target.content, allVariables, cookieJar, fileDir, fileContent, undefined, // no progress callback
590
+ undefined, // no call stack
591
+ undefined, // no sequence args
592
+ apiDefinitions, undefined, // no tag filter
593
+ importResult.sequenceSources);
594
+ // Format the results
595
+ const lines = [
596
+ `# Test Result: ${result.name || target.name}`,
597
+ `**Environment**: ${effectiveEnv ?? 'none'} (available: ${availableEnvs.length > 0 ? availableEnvs.join(', ') : 'no .nornenv found'})`,
598
+ `**Status**: ${result.success ? 'PASSED ✅' : 'FAILED ❌'}`,
599
+ `**Duration**: ${result.duration}ms`,
600
+ '',
601
+ ];
602
+ // If there were unresolved variables, flag them for debugging
603
+ const unresolvedVars = Object.entries(allVariables).length === 0 && availableEnvs.length > 0;
604
+ if (unresolvedVars) {
605
+ lines.push('⚠️ **Warning**: No variables were resolved. Make sure you pass the `environment` parameter matching one of: ' + availableEnvs.join(', '));
606
+ lines.push('');
607
+ }
608
+ // Assertion results
609
+ if (result.assertionResults.length > 0) {
610
+ lines.push('## Assertions');
611
+ for (const a of result.assertionResults) {
612
+ const icon = a.passed ? '✅' : '❌';
613
+ lines.push(` ${icon} ${a.expression}`);
614
+ if (!a.passed && a.error) {
615
+ lines.push(` → ${a.error}`);
616
+ }
617
+ }
618
+ lines.push('');
619
+ }
620
+ // HTTP responses summary
621
+ if (result.responses.length > 0) {
622
+ lines.push('## HTTP Responses');
623
+ for (let i = 0; i < result.responses.length; i++) {
624
+ const r = result.responses[i];
625
+ lines.push(` ${i + 1}. ${r.status} ${r.statusText} (${r.duration}ms)`);
626
+ }
627
+ lines.push('');
628
+ }
629
+ // Errors
630
+ if (result.errors.length > 0) {
631
+ lines.push('## Errors');
632
+ for (const e of result.errors) {
633
+ lines.push(` ❌ ${e}`);
634
+ }
635
+ }
636
+ return lines.join('\n');
637
+ }
638
+ catch (err) {
639
+ const message = err instanceof Error ? err.message : String(err);
640
+ const envInfo = availableEnvs.length > 0
641
+ ? ` (environment: ${effectiveEnv ?? 'none'}, available: ${availableEnvs.join(', ')})`
642
+ : '';
643
+ return `Error running sequence "${target.name}"${envInfo}: ${message}`;
644
+ }
645
+ }
646
+ function getActiveNornFileContext() {
647
+ const editor = vscode.window.activeTextEditor;
648
+ if (!editor) {
649
+ return undefined;
650
+ }
651
+ const doc = editor.document;
652
+ const lang = doc.languageId;
653
+ if (lang !== 'norn' && lang !== 'nornapi' && lang !== 'nornenv') {
654
+ return undefined;
655
+ }
656
+ const text = doc.getText();
657
+ // Skip very large files to stay within token budget
658
+ if (text.length > MAX_CONTEXT_CHARS) {
659
+ const truncated = text.substring(0, MAX_CONTEXT_CHARS);
660
+ return {
661
+ prompt: `The user has the following .${lang} file open (truncated):\n\`\`\`${lang}\n${truncated}\n\`\`\``,
662
+ uri: doc.uri,
663
+ };
664
+ }
665
+ return {
666
+ prompt: `The user has the following .${lang} file open:\n\`\`\`${lang}\n${text}\n\`\`\``,
667
+ uri: doc.uri,
668
+ };
669
+ }
670
+ /**
671
+ * Build the final user prompt, adjusting instructions based on the slash command.
672
+ */
673
+ function buildUserPrompt(command, prompt) {
674
+ switch (command) {
675
+ case 'generate':
676
+ return [
677
+ 'The user wants you to generate Norn code. Based on their description, produce a complete, valid .norn snippet.',
678
+ 'Include appropriate assertions (status, body values, types) and use best practices.',
679
+ 'If the description mentions an API or endpoint, generate a test sequence with realistic assertions.',
680
+ 'Wrap the output in ```norn code fences.',
681
+ '',
682
+ `User description: ${prompt}`,
683
+ ].join('\n');
684
+ case 'explain':
685
+ return [
686
+ 'The user wants you to explain Norn code. Analyze the code in their active file (provided above) or in their message.',
687
+ 'Explain step by step what the code does, covering: requests, variable assignments, assertions, control flow, and any imports.',
688
+ 'If there are potential issues or improvements, mention them.',
689
+ '',
690
+ `User request: ${prompt || 'Explain the current file'}`,
691
+ ].join('\n');
692
+ case 'convert':
693
+ return [
694
+ 'The user wants to convert something to Norn syntax. This could be:',
695
+ '- A cURL command → .norn request',
696
+ '- A Postman collection snippet → .norn file',
697
+ '- Raw HTTP → .norn request',
698
+ '- Another format → equivalent Norn syntax',
699
+ 'Produce valid .norn output. Preserve headers, body, and method. Wrap in ```norn code fences.',
700
+ '',
701
+ `User input: ${prompt}`,
702
+ ].join('\n');
703
+ default:
704
+ // General question — no extra framing needed, the system prompt is enough
705
+ return prompt;
706
+ }
707
+ }
708
+ function handleLlmError(err, stream) {
709
+ if (err instanceof vscode.LanguageModelError) {
710
+ console.error('Norn Chat: LLM error', err.message, err.code);
711
+ if (err.cause instanceof Error && err.cause.message.includes('off_topic')) {
712
+ stream.markdown('I can only help with **Norn REST client** questions — writing requests, sequences, assertions, environments, and CLI usage. Please ask something related to Norn.');
713
+ }
714
+ else {
715
+ stream.markdown(`Something went wrong communicating with the language model. Please try again.\n\n*Error: ${err.message}*`);
716
+ }
717
+ }
718
+ else {
719
+ throw err;
720
+ }
721
+ }
722
+ //# sourceMappingURL=chatParticipant.js.map