@xdelivered/emberflow 0.2.0 → 0.5.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 (116) hide show
  1. package/bin/commands.ts +7 -3
  2. package/bin/init.test.ts +94 -0
  3. package/bin/init.ts +108 -2
  4. package/dist/bin/commands.d.ts +1 -0
  5. package/dist/bin/commands.js +6 -3
  6. package/dist/bin/init.d.ts +4 -0
  7. package/dist/bin/init.js +96 -2
  8. package/dist/server/agents/codexParse.js +31 -0
  9. package/dist/server/agents/detect.d.ts +18 -8
  10. package/dist/server/agents/detect.js +78 -13
  11. package/dist/server/agents/modelRejectionHint.js +1 -1
  12. package/dist/server/agents/prompt.d.ts +15 -1
  13. package/dist/server/agents/prompt.js +59 -37
  14. package/dist/server/agents/runManager.d.ts +23 -2
  15. package/dist/server/agents/runManager.js +36 -8
  16. package/dist/server/client.d.ts +1 -0
  17. package/dist/server/client.js +7 -2
  18. package/dist/server/index.js +208 -16
  19. package/dist/server/nodesPayload.d.ts +14 -1
  20. package/dist/server/nodesPayload.js +15 -2
  21. package/dist/server/projectMode.js +5 -2
  22. package/dist/server/runRegistry.d.ts +52 -1
  23. package/dist/server/runRegistry.js +170 -1
  24. package/dist/server/sourceNav.d.ts +77 -0
  25. package/dist/server/sourceNav.js +503 -0
  26. package/dist/server/subflowRunner.d.ts +48 -1
  27. package/dist/server/subflowRunner.js +34 -7
  28. package/dist/server/updateCheck.d.ts +68 -0
  29. package/dist/server/updateCheck.js +142 -0
  30. package/dist/src/engine/executor.js +4 -3
  31. package/dist/src/engine/registry.d.ts +27 -2
  32. package/dist/src/engine/registry.js +84 -2
  33. package/dist/src/nodes/index.d.ts +8 -2
  34. package/dist/src/nodes/index.js +7 -3
  35. package/dist/src/nodes/login.d.ts +3 -1
  36. package/dist/src/nodes/login.js +2 -2
  37. package/package.json +28 -7
  38. package/server/agentRoute.test.ts +20 -0
  39. package/server/agents/codexParse.test.ts +37 -0
  40. package/server/agents/codexParse.ts +34 -0
  41. package/server/agents/detect.test.ts +26 -7
  42. package/server/agents/detect.ts +82 -14
  43. package/server/agents/modelRejectionHint.ts +2 -1
  44. package/server/agents/prompt.test.ts +128 -0
  45. package/server/agents/prompt.ts +102 -3
  46. package/server/agents/runManager.test.ts +9 -0
  47. package/server/agents/runManager.ts +37 -7
  48. package/server/client.ts +10 -4
  49. package/server/index.ts +213 -15
  50. package/server/nodeRun.test.ts +189 -0
  51. package/server/nodesPayload.test.ts +38 -0
  52. package/server/nodesPayload.ts +28 -3
  53. package/server/projectMode.ts +5 -2
  54. package/server/runRegistry.ts +200 -3
  55. package/server/setupStatus.test.ts +3 -0
  56. package/server/sourceNav.test.ts +382 -0
  57. package/server/sourceNav.ts +644 -0
  58. package/server/sourceNavRoute.test.ts +163 -0
  59. package/server/stepDrill.test.ts +380 -0
  60. package/server/subflowRunner.ts +92 -11
  61. package/server/updateCheck.test.ts +209 -0
  62. package/server/updateCheck.ts +175 -0
  63. package/server/workflowTestRoute.test.ts +1 -1
  64. package/src/App.tsx +82 -2
  65. package/src/components/AgentConsole.tsx +7 -280
  66. package/src/components/AgentStream.test.tsx +88 -0
  67. package/src/components/AgentStream.tsx +303 -0
  68. package/src/components/CreateModal.tsx +43 -9
  69. package/src/components/Dock.tsx +1 -1
  70. package/src/components/EmptyState.test.tsx +49 -0
  71. package/src/components/EmptyState.tsx +61 -0
  72. package/src/components/EnvironmentDialog.tsx +4 -1
  73. package/src/components/EnvironmentPicker.tsx +8 -3
  74. package/src/components/EnvironmentsDialog.tsx +4 -1
  75. package/src/components/InfraPanel.tsx +30 -2
  76. package/src/components/InfrastructureDialog.test.tsx +97 -0
  77. package/src/components/InfrastructureDialog.tsx +111 -0
  78. package/src/components/Inspector.tsx +9 -26
  79. package/src/components/RunbookView.tsx +70 -6
  80. package/src/components/SettingsDialog.tsx +4 -59
  81. package/src/components/Sidebar.tsx +6 -26
  82. package/src/components/SourceNavigator.test.tsx +226 -0
  83. package/src/components/SourceNavigator.tsx +520 -0
  84. package/src/components/StatusBar.tsx +227 -26
  85. package/src/components/Toolbar.tsx +28 -16
  86. package/src/components/UpdateChip.test.tsx +31 -0
  87. package/src/components/WelcomeDialog.test.tsx +384 -13
  88. package/src/components/WelcomeDialog.tsx +996 -177
  89. package/src/engine/executor.ts +4 -3
  90. package/src/engine/registry.sourceRef.test.ts +112 -0
  91. package/src/engine/registry.ts +103 -2
  92. package/src/lib/guidedQuestions.test.ts +224 -0
  93. package/src/lib/guidedQuestions.ts +181 -0
  94. package/src/lib/runbookModel.ts +3 -3
  95. package/src/lib/runbookProjection.test.ts +43 -0
  96. package/src/lib/runbookProjection.ts +26 -6
  97. package/src/nodes/index.ts +10 -3
  98. package/src/nodes/login.ts +5 -2
  99. package/src/store/agentClient.ts +27 -8
  100. package/src/store/apiTree.ts +12 -0
  101. package/src/store/builderStore.test.ts +441 -99
  102. package/src/store/builderStore.ts +385 -314
  103. package/src/store/nodeMeta.ts +10 -2
  104. package/src/store/serverRunner.ts +56 -5
  105. package/src/store/setupClient.ts +7 -2
  106. package/src/store/sourceNavClient.ts +48 -0
  107. package/src/store/updateClient.ts +50 -0
  108. package/studio-dist/assets/index-BbzppDpt.js +65 -0
  109. package/studio-dist/assets/index-CLf6blcA.css +1 -0
  110. package/studio-dist/index.html +2 -2
  111. package/templates/skills/emberflow-basics/SKILL.md +29 -1
  112. package/templates/skills/emberflow-model-process/SKILL.md +92 -9
  113. package/templates/skills/emberflow-new-workflow/SKILL.md +8 -1
  114. package/templates/skills/emberflow-review-workflow/SKILL.md +64 -1
  115. package/studio-dist/assets/index-DNJwW-hM.css +0 -1
  116. package/studio-dist/assets/index-O26dKRjW.js +0 -65
@@ -60,11 +60,12 @@ function envRefName(value: unknown): string | undefined {
60
60
  function resolveEnvRef(name: string, vars: Record<string, string>): string {
61
61
  if (!(name in vars)) {
62
62
  // The most common cause is not a missing key but a run with NO environment
63
- // at all: the in-tab engine has no vars, so flows seeded with $env refs
64
- // need the server runner. Say so, instead of a bare "missing".
63
+ // at all: an environment-less run (e.g. a scenario test) has no vars, so
64
+ // flows seeded with $env refs need a real environment. Say so, instead of a
65
+ // bare "missing".
65
66
  const hint =
66
67
  Object.keys(vars).length === 0
67
- ? ' — this run has no environment variables (in-tab/browser runs never do; make sure the runner is online and the engine is set to Server or Auto)'
68
+ ? ' — this run has no environment variables; run it against an environment that defines them'
68
69
  : '';
69
70
  throw new Error(`Missing environment variable: ${name}${hint}`);
70
71
  }
@@ -0,0 +1,112 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { fileURLToPath, pathToFileURL } from 'node:url';
6
+ import { afterAll, describe, expect, it } from 'vitest';
7
+ import { NodeRegistry } from './registry';
8
+
9
+ // realpath: tmpdir() on macOS returns a /var/folders symlink; module loaders
10
+ // report the resolved /private/var path, so compare against the real path.
11
+ const tmp = realpathSync(mkdtempSync(join(tmpdir(), 'registry-sourceref-')));
12
+ afterAll(() => rmSync(tmp, { recursive: true, force: true }));
13
+
14
+ describe('NodeRegistry sourceRef capture', () => {
15
+ it('does not capture by default (browser registries never pay)', () => {
16
+ const r = new NodeRegistry();
17
+ r.register({ type: 'A', label: 'A' }, async () => ({}));
18
+ expect(r.getSourceRef('A')).toBeUndefined();
19
+ });
20
+
21
+ it('explicit opts.sourceRef wins over automatic capture', () => {
22
+ const r = new NodeRegistry({ captureSourceRefs: true });
23
+ r.register({ type: 'B', label: 'B' }, async () => ({}), {
24
+ sourceRef: { file: '/x/custom.ts', line: 7 },
25
+ });
26
+ expect(r.getSourceRef('B')).toEqual({ file: '/x/custom.ts', line: 7 });
27
+ });
28
+
29
+ it('explicit opts.sourceRef is stored even when capture is off', () => {
30
+ const r = new NodeRegistry();
31
+ r.register({ type: 'B2', label: 'B2' }, async () => ({}), {
32
+ sourceRef: { file: '/x/other.mjs', line: 2 },
33
+ });
34
+ expect(r.getSourceRef('B2')).toEqual({ file: '/x/other.mjs', line: 2 });
35
+ });
36
+
37
+ it('captures the exact caller file and line from an imported .mjs module (real loader)', () => {
38
+ // Runs under a child tsx process: vitest's vite-node transform wraps
39
+ // dynamically imported modules and shifts line numbers, so in-process
40
+ // assertions would test the transform, not the capture. tsx/node load
41
+ // a plain .mjs verbatim — that is what production does.
42
+ const fixture = join(tmp, 'nodes.mjs');
43
+ writeFileSync(
44
+ fixture,
45
+ [
46
+ '// fixture module that registers a node — the register() call is on line 3',
47
+ 'export function registerFixtureNodes(registry) {',
48
+ ' registry.register({ type: "FixtureNode", label: "Fixture" }, async () => ({ ok: true }));',
49
+ '}',
50
+ '',
51
+ ].join('\n'),
52
+ );
53
+ const registryPath = fileURLToPath(new URL('./registry.ts', import.meta.url));
54
+ const driver = join(tmp, 'driver.mjs');
55
+ writeFileSync(
56
+ driver,
57
+ [
58
+ `import { NodeRegistry } from ${JSON.stringify(pathToFileURL(registryPath).href)};`,
59
+ `import { registerFixtureNodes } from ${JSON.stringify(pathToFileURL(fixture).href)};`,
60
+ 'const r = new NodeRegistry({ captureSourceRefs: true });',
61
+ 'registerFixtureNodes(r);',
62
+ "console.log(JSON.stringify(r.getSourceRef('FixtureNode') ?? null));",
63
+ '',
64
+ ].join('\n'),
65
+ );
66
+ const out = execFileSync('npx', ['tsx', driver], {
67
+ cwd: fileURLToPath(new URL('../..', import.meta.url)),
68
+ encoding: 'utf8',
69
+ timeout: 30_000,
70
+ });
71
+ const ref = JSON.parse(out.trim().split('\n').pop()!) as { file: string; line?: number } | null;
72
+ expect(ref).not.toBeNull();
73
+ expect(ref!.file).toBe(fixture);
74
+ expect(ref!.line).toBe(3);
75
+ }, 40_000);
76
+
77
+ it('captures a plain path (no file:// prefix, no query string) from TS caller frames', () => {
78
+ const r = new NodeRegistry({ captureSourceRefs: true });
79
+ r.register({ type: 'C', label: 'C' }, async () => ({}));
80
+ const ref = r.getSourceRef('C');
81
+ expect(ref).toBeDefined();
82
+ expect(ref!.file).toContain('registry.sourceRef.test');
83
+ expect(ref!.file.startsWith('file://')).toBe(false);
84
+ expect(ref!.file).not.toContain('?');
85
+ expect(typeof ref!.line).toBe('number');
86
+ });
87
+
88
+ it('restores Error.prepareStackTrace after capture', () => {
89
+ const sentinel = (): string => 'sentinel';
90
+ const original = Error.prepareStackTrace;
91
+ Error.prepareStackTrace = sentinel as unknown as typeof Error.prepareStackTrace;
92
+ try {
93
+ const r = new NodeRegistry({ captureSourceRefs: true });
94
+ r.register({ type: 'D', label: 'D' }, async () => ({}));
95
+ expect(Error.prepareStackTrace).toBe(sentinel);
96
+ } finally {
97
+ Error.prepareStackTrace = original;
98
+ }
99
+ });
100
+
101
+ it('adopt() and withSameNodes() carry sourceRefs', () => {
102
+ const a = new NodeRegistry({ captureSourceRefs: true });
103
+ a.register({ type: 'E', label: 'E' }, async () => ({}), {
104
+ sourceRef: { file: '/p/e.mjs', line: 1 },
105
+ });
106
+ const shared = a.withSameNodes();
107
+ expect(shared.getSourceRef('E')).toEqual({ file: '/p/e.mjs', line: 1 });
108
+ const b = new NodeRegistry();
109
+ b.adopt(a);
110
+ expect(b.getSourceRef('E')).toEqual({ file: '/p/e.mjs', line: 1 });
111
+ });
112
+ });
@@ -5,15 +5,98 @@ export interface RegisteredNode {
5
5
  implementation: NodeImplementation;
6
6
  }
7
7
 
8
+ /** Where a node was registered from: absolute file path + 1-based line of the register() call. */
9
+ export interface SourceRef {
10
+ file: string;
11
+ line?: number;
12
+ }
13
+
14
+ export interface RegisterOptions {
15
+ /** Explicit provenance escape hatch — wins over automatic capture. */
16
+ sourceRef?: SourceRef;
17
+ }
18
+
19
+ /**
20
+ * V8 stack-frame shape (structural — this module must stay browser-safe, so
21
+ * no @types/node CallSite import).
22
+ */
23
+ interface V8CallSite {
24
+ getFileName?: () => string | null | undefined;
25
+ getLineNumber?: () => number | null | undefined;
26
+ }
27
+
28
+ /**
29
+ * Read the CALLER's file:line via a one-frame V8 stack capture.
30
+ * `Error.prepareStackTrace` is swapped for the duration of a single `new
31
+ * Error()` and restored in `finally`, so source-map hooks installed by
32
+ * tsx/vitest are back in place immediately. Frames arrive either as
33
+ * `file:///abs/path.ts` URLs (ESM) or plain absolute paths (CJS/transpilers);
34
+ * both normalize to a plain path. Returns undefined on any non-V8 runtime or
35
+ * anonymous/eval frames — capture is best-effort by design.
36
+ */
37
+ function captureCallerSourceRef(skip: (...args: never[]) => unknown): SourceRef | undefined {
38
+ if (typeof Error.captureStackTrace !== 'function') return undefined;
39
+ const original = Error.prepareStackTrace;
40
+ try {
41
+ Error.prepareStackTrace = (_err, frames) => frames;
42
+ const holder: { stack?: unknown } = {};
43
+ Error.captureStackTrace(holder as Error, skip);
44
+ const frames = holder.stack;
45
+ if (!Array.isArray(frames) || frames.length === 0) return undefined;
46
+ const frame = frames[0] as V8CallSite;
47
+ const raw = typeof frame.getFileName === 'function' ? frame.getFileName() : undefined;
48
+ if (typeof raw !== 'string' || raw.length === 0) return undefined;
49
+ const lineRaw = typeof frame.getLineNumber === 'function' ? frame.getLineNumber() : undefined;
50
+ const line = typeof lineRaw === 'number' && lineRaw > 0 ? lineRaw : undefined;
51
+ let file = raw;
52
+ if (file.startsWith('file://')) {
53
+ // No node:url here (browser-safe module): URL parsing covers the
54
+ // file:///abs/path form these frames take on POSIX platforms.
55
+ try {
56
+ file = decodeURIComponent(new URL(file).pathname);
57
+ } catch {
58
+ return undefined;
59
+ }
60
+ }
61
+ // Loaders (vite-node hot imports) may suffix ?t=... cache-busters.
62
+ const q = file.indexOf('?');
63
+ if (q !== -1) file = file.slice(0, q);
64
+ return line !== undefined ? { file, line } : { file };
65
+ } catch {
66
+ return undefined;
67
+ } finally {
68
+ Error.prepareStackTrace = original;
69
+ }
70
+ }
71
+
8
72
  export class NodeRegistry {
9
73
  nodes = new Map<string, RegisteredNode>();
10
74
  sources = new Map<string, string>();
75
+ /** Registration provenance per node type. Populated only when capture is on or opts.sourceRef given. */
76
+ sourceRefs = new Map<string, SourceRef>();
77
+ /** Types the RUNNER flagged builtin (registered from the package, not the project). */
78
+ builtinTypes = new Set<string>();
79
+ /** Automatic caller capture is opt-in (server registries only) so browser registries never pay/leak. */
80
+ private captureSourceRefs: boolean;
81
+
82
+ constructor(opts?: { captureSourceRefs?: boolean }) {
83
+ this.captureSourceRefs = opts?.captureSourceRefs ?? false;
84
+ }
11
85
 
12
- register(definition: NodeDefinition, implementation: NodeImplementation): void {
86
+ register(definition: NodeDefinition, implementation: NodeImplementation, opts?: RegisterOptions): void {
13
87
  if (this.nodes.has(definition.type)) {
14
88
  throw new Error(`Node type already registered: ${definition.type}`);
15
89
  }
16
90
  this.nodes.set(definition.type, { definition, implementation });
91
+ const ref =
92
+ opts?.sourceRef ??
93
+ (this.captureSourceRefs ? captureCallerSourceRef(this.register) : undefined);
94
+ if (ref) this.sourceRefs.set(definition.type, ref);
95
+ }
96
+
97
+ /** Where `type` was registered from, when known. */
98
+ getSourceRef(type: string): SourceRef | undefined {
99
+ return this.sourceRefs.get(type);
17
100
  }
18
101
 
19
102
  get(type: string): RegisteredNode {
@@ -36,6 +119,13 @@ export class NodeRegistry {
36
119
  adopt(other: NodeRegistry): void {
37
120
  this.nodes = other.nodes;
38
121
  this.sources = other.sources;
122
+ this.sourceRefs = other.sourceRefs;
123
+ this.builtinTypes = other.builtinTypes;
124
+ }
125
+
126
+ /** Whether the runner flagged `type` as a package built-in (no servable source file). */
127
+ isBuiltin(type: string): boolean {
128
+ return this.builtinTypes.has(type);
39
129
  }
40
130
 
41
131
  list(): NodeDefinition[] {
@@ -47,7 +137,16 @@ export class NodeRegistry {
47
137
  * implementation is a stub that fails loudly if browser-executed — such
48
138
  * nodes run on the server. Never overwrites a real registration.
49
139
  */
50
- registerDefinition(definition: NodeDefinition, source?: string): void {
140
+ registerDefinition(
141
+ definition: NodeDefinition,
142
+ source?: string,
143
+ opts?: { sourceRef?: SourceRef; builtin?: boolean },
144
+ ): void {
145
+ // Provenance is recorded even for already-registered types: the studio's
146
+ // bundled built-ins are real registrations, and the runner's builtin flag
147
+ // (or a project sourceRef shadowing one) must still land on them.
148
+ if (opts?.sourceRef) this.sourceRefs.set(definition.type, opts.sourceRef);
149
+ if (opts?.builtin) this.builtinTypes.add(definition.type);
51
150
  if (this.nodes.has(definition.type)) return;
52
151
  this.nodes.set(definition.type, {
53
152
  definition,
@@ -75,6 +174,8 @@ export class NodeRegistry {
75
174
  const next = new NodeRegistry();
76
175
  next.nodes = this.nodes;
77
176
  next.sources = this.sources;
177
+ next.sourceRefs = this.sourceRefs;
178
+ next.builtinTypes = this.builtinTypes;
78
179
  return next;
79
180
  }
80
181
  }
@@ -0,0 +1,224 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ composeAnsweredSubset,
4
+ extractGuidedQuestions,
5
+ resolveGuidedAnswers,
6
+ } from './guidedQuestions';
7
+ import type { GuidedQuestion } from './guidedQuestions';
8
+
9
+ const block = (json: string) => '```emberflow-questions\n' + json + '\n```';
10
+
11
+ describe('extractGuidedQuestions', () => {
12
+ it('happy path: strips a trailing block and normalizes string options', () => {
13
+ const text =
14
+ 'Steps 1–4 are done.\n\n' +
15
+ block('{"questions":[{"id":"envs","text":"Which environments?","options":["dev + prod","dev + staging + prod"],"custom":true}]}');
16
+ const { stripped, questions } = extractGuidedQuestions(text);
17
+ expect(stripped).toBe('Steps 1–4 are done.');
18
+ expect(questions).toEqual([
19
+ {
20
+ id: 'envs',
21
+ text: 'Which environments?',
22
+ options: [{ label: 'dev + prod' }, { label: 'dev + staging + prod' }],
23
+ custom: true,
24
+ },
25
+ ]);
26
+ });
27
+
28
+ it('tolerates trailing whitespace/newlines after the closing fence', () => {
29
+ const text =
30
+ 'Done.\n' + block('{"questions":[{"id":"a","text":"Q?","options":["x"]}]}') + ' \n\n';
31
+ const { stripped, questions } = extractGuidedQuestions(text);
32
+ expect(stripped).toBe('Done.');
33
+ expect(questions).toHaveLength(1);
34
+ });
35
+
36
+ it('no block: returns the text unchanged with null questions', () => {
37
+ const text = 'Just prose, with `inline code` and a ```js\nfence\n``` in the middle. The end.';
38
+ expect(extractGuidedQuestions(text)).toEqual({ stripped: text, questions: null });
39
+ });
40
+
41
+ it('malformed JSON: leaves the text untouched so nothing is silently lost', () => {
42
+ const text = 'Prose.\n' + block('{"questions": [oops');
43
+ expect(extractGuidedQuestions(text)).toEqual({ stripped: text, questions: null });
44
+ });
45
+
46
+ it('invalid shape (questions not an array / bad entries): text untouched, null', () => {
47
+ for (const json of [
48
+ '{"questions":"nope"}',
49
+ '{}',
50
+ '{"questions":[]}',
51
+ '{"questions":[{"id":"a","options":["x"]}]}', // missing text
52
+ '{"questions":[{"id":"a","text":"Q?","options":[42]}]}', // bad option
53
+ '{"questions":[{"id":"a","text":"Q?","options":[]}]}', // unanswerable
54
+ ]) {
55
+ const text = 'Prose.\n' + block(json);
56
+ expect(extractGuidedQuestions(text)).toEqual({ stripped: text, questions: null });
57
+ }
58
+ });
59
+
60
+ it('block not at the end: ignored (contract puts it last)', () => {
61
+ const text =
62
+ block('{"questions":[{"id":"a","text":"Q?","options":["x"]}]}') + '\nTrailing prose after.';
63
+ expect(extractGuidedQuestions(text)).toEqual({ stripped: text, questions: null });
64
+ });
65
+
66
+ it('mixed string and object options, including a finish action', () => {
67
+ const text = block(
68
+ '{"questions":[{"id":"first-build","text":"What first?","options":["An op",{"label":"Just look around","action":"finish"}],"custom":true}]}',
69
+ );
70
+ const { stripped, questions } = extractGuidedQuestions(text);
71
+ expect(stripped).toBe('');
72
+ expect(questions).toEqual([
73
+ {
74
+ id: 'first-build',
75
+ text: 'What first?',
76
+ options: [{ label: 'An op' }, { label: 'Just look around', action: 'finish' }],
77
+ custom: true,
78
+ },
79
+ ]);
80
+ });
81
+
82
+ it('drops unknown option actions rather than forwarding them', () => {
83
+ const { questions } = extractGuidedQuestions(
84
+ block('{"questions":[{"id":"a","text":"Q?","options":[{"label":"x","action":"explode"}]}]}'),
85
+ );
86
+ expect(questions).toEqual([{ id: 'a', text: 'Q?', options: [{ label: 'x' }] }]);
87
+ });
88
+
89
+ it('keeps a submit action on an option', () => {
90
+ const { questions } = extractGuidedQuestions(
91
+ block('{"questions":[{"id":"a","text":"Q?","options":[{"label":"Go","action":"submit"}]}]}'),
92
+ );
93
+ expect(questions).toEqual([
94
+ { id: 'a', text: 'Q?', options: [{ label: 'Go', action: 'submit' }] },
95
+ ]);
96
+ });
97
+
98
+ it('keeps a non-empty why (trimmed); drops empty/whitespace/non-string ones', () => {
99
+ const { questions } = extractGuidedQuestions(
100
+ block('{"questions":[{"id":"a","text":"Q?","options":["x"],"why":" So mocks stay safe. "}]}'),
101
+ );
102
+ expect(questions).toEqual([
103
+ { id: 'a', text: 'Q?', options: [{ label: 'x' }], why: 'So mocks stay safe.' },
104
+ ]);
105
+
106
+ for (const why of ['""', '" "', '42', 'null', '["nope"]']) {
107
+ const { questions: qs } = extractGuidedQuestions(
108
+ block(`{"questions":[{"id":"a","text":"Q?","options":["x"],"why":${why}}]}`),
109
+ );
110
+ expect(qs).toEqual([{ id: 'a', text: 'Q?', options: [{ label: 'x' }] }]);
111
+ }
112
+ });
113
+ });
114
+
115
+ const QUESTIONS: GuidedQuestion[] = [
116
+ { id: 'envs', text: 'Which environments?', options: [{ label: 'dev + prod' }], custom: true },
117
+ {
118
+ id: 'first-build',
119
+ text: 'What do you want to build first?',
120
+ options: [{ label: 'Just look around', action: 'finish' }],
121
+ custom: true,
122
+ },
123
+ ];
124
+
125
+ describe('resolveGuidedAnswers', () => {
126
+ it('incomplete until every question has a pill or custom text', () => {
127
+ expect(resolveGuidedAnswers(QUESTIONS, {})).toEqual({ kind: 'incomplete' });
128
+ expect(
129
+ resolveGuidedAnswers(QUESTIONS, { envs: { option: { label: 'dev + prod' } } }),
130
+ ).toEqual({ kind: 'incomplete' });
131
+ // Whitespace-only custom text does not count as an answer.
132
+ expect(
133
+ resolveGuidedAnswers(QUESTIONS, {
134
+ envs: { option: { label: 'dev + prod' } },
135
+ 'first-build': { text: ' ' },
136
+ }),
137
+ ).toEqual({ kind: 'incomplete' });
138
+ });
139
+
140
+ it('composes one "<question>: <answer>" line per question', () => {
141
+ expect(
142
+ resolveGuidedAnswers(QUESTIONS, {
143
+ envs: { option: { label: 'dev + prod' } },
144
+ 'first-build': { text: ' a health-check op ' },
145
+ }),
146
+ ).toEqual({
147
+ kind: 'send',
148
+ text: 'Which environments?: dev + prod\nWhat do you want to build first?: a health-check op',
149
+ });
150
+ });
151
+
152
+ it('any picked finish option resolves to finish — nothing is sent', () => {
153
+ expect(
154
+ resolveGuidedAnswers(QUESTIONS, {
155
+ envs: { option: { label: 'dev + prod' } },
156
+ 'first-build': { option: { label: 'Just look around', action: 'finish' } },
157
+ }),
158
+ ).toEqual({ kind: 'finish' });
159
+ });
160
+
161
+ describe('first-build routing', () => {
162
+ const FIRST_BUILD_ONLY: GuidedQuestion[] = [QUESTIONS[1]];
163
+
164
+ it('sole first-build question answered with custom text routes to build', () => {
165
+ expect(
166
+ resolveGuidedAnswers(FIRST_BUILD_ONLY, {
167
+ 'first-build': { text: ' an invoicing API ' },
168
+ }),
169
+ ).toEqual({ kind: 'build', text: 'an invoicing API' });
170
+ });
171
+
172
+ it('sole first-build question answered with a plain pill still sends', () => {
173
+ const q: GuidedQuestion[] = [
174
+ { id: 'first-build', text: 'What first?', options: [{ label: 'An op' }], custom: true },
175
+ ];
176
+ expect(resolveGuidedAnswers(q, { 'first-build': { option: { label: 'An op' } } })).toEqual({
177
+ kind: 'send',
178
+ text: 'What first?: An op',
179
+ });
180
+ });
181
+
182
+ it('first-build custom text alongside OTHER answers keeps the composed send', () => {
183
+ expect(
184
+ resolveGuidedAnswers(QUESTIONS, {
185
+ envs: { option: { label: 'dev + prod' } },
186
+ 'first-build': { text: 'an invoicing API' },
187
+ }),
188
+ ).toEqual({
189
+ kind: 'send',
190
+ text: 'Which environments?: dev + prod\nWhat do you want to build first?: an invoicing API',
191
+ });
192
+ });
193
+
194
+ it('a sole non-first-build question with custom text still sends', () => {
195
+ expect(
196
+ resolveGuidedAnswers([QUESTIONS[0]], { envs: { text: 'dev only' } }),
197
+ ).toEqual({ kind: 'send', text: 'Which environments?: dev only' });
198
+ });
199
+ });
200
+ });
201
+
202
+ describe('composeAnsweredSubset', () => {
203
+ it('composes only the answered questions, skipping unanswered ones', () => {
204
+ expect(
205
+ composeAnsweredSubset(QUESTIONS, {
206
+ 'first-build': { option: { label: 'Skip ahead', action: 'submit' } },
207
+ }),
208
+ ).toBe('What do you want to build first?: Skip ahead');
209
+ });
210
+
211
+ it('includes pill and trimmed custom-text answers in question order', () => {
212
+ expect(
213
+ composeAnsweredSubset(QUESTIONS, {
214
+ 'first-build': { text: ' a health-check op ' },
215
+ envs: { option: { label: 'dev + prod' } },
216
+ }),
217
+ ).toBe('Which environments?: dev + prod\nWhat do you want to build first?: a health-check op');
218
+ });
219
+
220
+ it('whitespace-only custom text is not an answer; nothing answered composes empty', () => {
221
+ expect(composeAnsweredSubset(QUESTIONS, { envs: { text: ' ' } })).toBe('');
222
+ expect(composeAnsweredSubset(QUESTIONS, {})).toBe('');
223
+ });
224
+ });
@@ -0,0 +1,181 @@
1
+ /**
2
+ * The guided-setup agent ends messages that need the user's input with a fenced
3
+ * ```emberflow-questions block (see server/agents/prompt.ts, 'guided-setup'):
4
+ * JSON describing clickable questions the studio renders as a form instead of
5
+ * a raw code block. This module owns the contract's client side — extraction,
6
+ * defensive parsing/normalization, and turning the user's picks back into the
7
+ * plaintext continuation message the agent expects.
8
+ */
9
+
10
+ export interface GuidedQuestionOption {
11
+ label: string;
12
+ /** 'finish' ends onboarding client-side instead of sending a run; 'submit'
13
+ * records the answer and immediately sends whatever has been answered so
14
+ * far (a subset — unanswered questions are simply skipped). */
15
+ action?: 'finish' | 'submit';
16
+ }
17
+
18
+ export interface GuidedQuestion {
19
+ id: string;
20
+ text: string;
21
+ options: GuidedQuestionOption[];
22
+ /** Also offer a free-text field alongside the clickable options. */
23
+ custom?: boolean;
24
+ /** One-line rationale rendered under the question text. */
25
+ why?: string;
26
+ }
27
+
28
+ /** Trailing fenced block: ```emberflow-questions\n<json>\n``` and then nothing
29
+ * but whitespace. The prompt contract puts the block LAST; anything after the
30
+ * closing fence means it's not the interactive form. */
31
+ const TRAILING_BLOCK = /```emberflow-questions[ \t]*\n([\s\S]*?)\n?```\s*$/;
32
+
33
+ function normalizeOption(raw: unknown): GuidedQuestionOption | null {
34
+ if (typeof raw === 'string') return { label: raw };
35
+ if (raw && typeof raw === 'object' && typeof (raw as { label?: unknown }).label === 'string') {
36
+ const { label, action } = raw as { label: string; action?: unknown };
37
+ // Unknown actions are dropped (the option stays clickable, just inert).
38
+ return action === 'finish' || action === 'submit' ? { label, action } : { label };
39
+ }
40
+ return null;
41
+ }
42
+
43
+ function normalizeQuestion(raw: unknown): GuidedQuestion | null {
44
+ if (!raw || typeof raw !== 'object') return null;
45
+ const { id, text, options, custom, why } = raw as {
46
+ id?: unknown;
47
+ text?: unknown;
48
+ options?: unknown;
49
+ custom?: unknown;
50
+ why?: unknown;
51
+ };
52
+ if (typeof id !== 'string' || typeof text !== 'string' || !Array.isArray(options)) return null;
53
+ const normalized: GuidedQuestionOption[] = [];
54
+ for (const o of options) {
55
+ const opt = normalizeOption(o);
56
+ if (!opt) return null;
57
+ normalized.push(opt);
58
+ }
59
+ // A question with no options and no free-text field is unanswerable.
60
+ if (normalized.length === 0 && custom !== true) return null;
61
+ // `why` must be a non-empty string; anything else is dropped, not fatal.
62
+ const rationale = typeof why === 'string' && why.trim() !== '' ? why.trim() : undefined;
63
+ return {
64
+ id,
65
+ text,
66
+ options: normalized,
67
+ ...(custom === true ? { custom: true } : {}),
68
+ ...(rationale !== undefined ? { why: rationale } : {}),
69
+ };
70
+ }
71
+
72
+ /**
73
+ * Find and strip a trailing `emberflow-questions` fenced block. Malformed JSON
74
+ * or an invalid shape returns the text UNTOUCHED with `questions: null` —
75
+ * nothing is silently lost; the stream just shows the raw block as prose.
76
+ */
77
+ export function extractGuidedQuestions(text: string): {
78
+ stripped: string;
79
+ questions: GuidedQuestion[] | null;
80
+ } {
81
+ const match = TRAILING_BLOCK.exec(text);
82
+ if (!match) return { stripped: text, questions: null };
83
+
84
+ let parsed: unknown;
85
+ try {
86
+ parsed = JSON.parse(match[1]);
87
+ } catch {
88
+ return { stripped: text, questions: null };
89
+ }
90
+ const rawQuestions = (parsed as { questions?: unknown } | null)?.questions;
91
+ if (!Array.isArray(rawQuestions) || rawQuestions.length === 0) {
92
+ return { stripped: text, questions: null };
93
+ }
94
+ const questions: GuidedQuestion[] = [];
95
+ for (const q of rawQuestions) {
96
+ const normalized = normalizeQuestion(q);
97
+ if (!normalized) return { stripped: text, questions: null };
98
+ questions.push(normalized);
99
+ }
100
+ return { stripped: text.slice(0, match.index).replace(/\s+$/, ''), questions };
101
+ }
102
+
103
+ /** One answer per question id: a picked option OR free text (never both). */
104
+ export type GuidedAnswers = Record<
105
+ string,
106
+ { option?: GuidedQuestionOption; text?: string } | undefined
107
+ >;
108
+
109
+ /** The agent's closing "what do you want to build first?" question — a custom
110
+ * answer to it (alone) routes into the real build flow, not a continuation. */
111
+ export const FIRST_BUILD_QUESTION_ID = 'first-build';
112
+
113
+ export type GuidedSubmit =
114
+ | { kind: 'incomplete' }
115
+ /** A selected option carried action 'finish' — end onboarding, send nothing. */
116
+ | { kind: 'finish' }
117
+ /** Composed plaintext continuation: one `<question>: <answer>` line each. */
118
+ | { kind: 'send'; text: string }
119
+ /** The ONLY answer is free text on the 'first-build' question — open the
120
+ * create-API flow pre-filled with the description instead of sending a run. */
121
+ | { kind: 'build'; text: string };
122
+
123
+ /**
124
+ * Resolve the form's submit: every question must be answered (pill or custom
125
+ * text); any picked 'finish' option wins over sending. When the form's sole
126
+ * question is 'first-build' and it was answered with custom text (not a pill),
127
+ * the submit routes to the build flow instead of a continuation run. Pure so
128
+ * the component test can exercise the submit path without DOM interactions.
129
+ */
130
+ export function resolveGuidedAnswers(
131
+ questions: GuidedQuestion[],
132
+ answers: GuidedAnswers,
133
+ ): GuidedSubmit {
134
+ const lines: string[] = [];
135
+ let finish = false;
136
+ for (const q of questions) {
137
+ const a = answers[q.id];
138
+ const custom = a?.text?.trim();
139
+ if (a?.option) {
140
+ if (a.option.action === 'finish') finish = true;
141
+ lines.push(`${q.text}: ${a.option.label}`);
142
+ } else if (custom) {
143
+ lines.push(`${q.text}: ${custom}`);
144
+ } else {
145
+ return { kind: 'incomplete' };
146
+ }
147
+ }
148
+ if (finish) return { kind: 'finish' };
149
+ // The only answered question is 'first-build' with a typed description —
150
+ // that's a build request, not an interview answer. Alongside OTHER answers
151
+ // it stays part of the composed continuation.
152
+ const soleBuildText =
153
+ questions.length === 1 && questions[0].id === FIRST_BUILD_QUESTION_ID
154
+ ? answers[questions[0].id]?.text?.trim()
155
+ : undefined;
156
+ if (soleBuildText && !answers[questions[0].id]?.option) {
157
+ return { kind: 'build', text: soleBuildText };
158
+ }
159
+ return { kind: 'send', text: lines.join('\n') };
160
+ }
161
+
162
+ /**
163
+ * Compose ONLY the answered questions into the same `<question>: <answer>`
164
+ * plaintext the full resolver sends — unanswered questions are skipped, not
165
+ * required. Powers a 'submit' option: clicking it records the answer and sends
166
+ * immediately with whatever the user has answered so far. Pure for the same
167
+ * reason as `resolveGuidedAnswers`.
168
+ */
169
+ export function composeAnsweredSubset(
170
+ questions: GuidedQuestion[],
171
+ answers: GuidedAnswers,
172
+ ): string {
173
+ const lines: string[] = [];
174
+ for (const q of questions) {
175
+ const a = answers[q.id];
176
+ const custom = a?.text?.trim();
177
+ if (a?.option) lines.push(`${q.text}: ${a.option.label}`);
178
+ else if (custom) lines.push(`${q.text}: ${custom}`);
179
+ }
180
+ return lines.join('\n');
181
+ }