@outputai/cli 0.8.1-next.e92f632.0 → 0.8.2-dev.e78f6b4.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 (61) hide show
  1. package/dist/assets/docker/docker-compose-dev.yml +1 -1
  2. package/dist/commands/workflow/cost.d.ts +3 -2
  3. package/dist/commands/workflow/cost.js +4 -11
  4. package/dist/commands/workflow/cost.spec.js +1 -3
  5. package/dist/commands/workflow/dataset/generate.d.ts +1 -0
  6. package/dist/commands/workflow/dataset/generate.js +18 -9
  7. package/dist/commands/workflow/dataset/generate.spec.d.ts +1 -0
  8. package/dist/commands/workflow/dataset/generate.spec.js +69 -0
  9. package/dist/commands/workflow/dataset/list.d.ts +3 -1
  10. package/dist/commands/workflow/dataset/list.js +10 -14
  11. package/dist/commands/workflow/debug.d.ts +2 -6
  12. package/dist/commands/workflow/debug.js +10 -29
  13. package/dist/commands/workflow/debug.spec.js +2 -5
  14. package/dist/commands/workflow/generate.spec.js +2 -2
  15. package/dist/commands/workflow/list.d.ts +4 -1
  16. package/dist/commands/workflow/list.js +15 -19
  17. package/dist/commands/workflow/list.spec.js +2 -1
  18. package/dist/commands/workflow/result.d.ts +3 -4
  19. package/dist/commands/workflow/result.js +6 -15
  20. package/dist/commands/workflow/result.spec.js +2 -4
  21. package/dist/commands/workflow/run.d.ts +3 -2
  22. package/dist/commands/workflow/run.js +5 -12
  23. package/dist/commands/workflow/run.spec.js +18 -3
  24. package/dist/commands/workflow/runs/list.d.ts +3 -1
  25. package/dist/commands/workflow/runs/list.js +11 -15
  26. package/dist/commands/workflow/start.js +1 -1
  27. package/dist/commands/workflow/start.spec.js +53 -2
  28. package/dist/commands/workflow/status.d.ts +3 -4
  29. package/dist/commands/workflow/status.js +20 -30
  30. package/dist/commands/workflow/status.spec.js +2 -4
  31. package/dist/commands/workflow/test_eval.d.ts +5 -2
  32. package/dist/commands/workflow/test_eval.js +28 -19
  33. package/dist/commands/workflow/test_eval.spec.d.ts +1 -0
  34. package/dist/commands/workflow/test_eval.spec.js +121 -0
  35. package/dist/generated/framework_version.json +1 -1
  36. package/dist/hooks/init.d.ts +1 -0
  37. package/dist/hooks/init.js +40 -30
  38. package/dist/hooks/init.spec.js +28 -4
  39. package/dist/services/coding_agents.spec.js +10 -10
  40. package/dist/services/datasets.d.ts +2 -2
  41. package/dist/services/datasets.js +19 -17
  42. package/dist/services/datasets.test.js +37 -2
  43. package/dist/utils/eval_diagnostics.d.ts +7 -0
  44. package/dist/utils/eval_diagnostics.js +35 -0
  45. package/dist/utils/format_workflow_result.spec.js +0 -13
  46. package/dist/utils/resolve_input.d.ts +1 -1
  47. package/dist/utils/resolve_input.js +2 -2
  48. package/dist/utils/scenario_resolver.d.ts +2 -3
  49. package/dist/utils/scenario_resolver.js +5 -35
  50. package/dist/utils/scenario_resolver.spec.js +14 -0
  51. package/dist/utils/trace_formatter.js +1 -2
  52. package/dist/utils/workflow_dir.d.ts +13 -0
  53. package/dist/utils/workflow_dir.js +58 -0
  54. package/dist/utils/workflow_dir.spec.d.ts +1 -0
  55. package/dist/utils/workflow_dir.spec.js +60 -0
  56. package/oclif.manifest.json +214 -201
  57. package/package.json +5 -5
  58. package/dist/utils/constants.d.ts +0 -5
  59. package/dist/utils/constants.js +0 -4
  60. package/dist/utils/output_formatter.d.ts +0 -2
  61. package/dist/utils/output_formatter.js +0 -11
@@ -0,0 +1,121 @@
1
+ /* eslint-disable @typescript-eslint/no-explicit-any */
2
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
3
+ import { getEvalWorkflowName, renderEvalOutput } from '@outputai/evals';
4
+ vi.mock('#api/generated/api.js', () => ({
5
+ postWorkflowRun: vi.fn()
6
+ }));
7
+ vi.mock('#api/workflow_catalog.js', () => ({
8
+ fetchWorkflowCatalog: vi.fn()
9
+ }));
10
+ vi.mock('#services/datasets.js', () => ({
11
+ readAllDatasets: vi.fn(),
12
+ writeDataset: vi.fn()
13
+ }));
14
+ vi.mock('#utils/eval_diagnostics.js', () => ({
15
+ diagnoseMissingEvalWorkflow: vi.fn().mockResolvedValue('missing eval workflow')
16
+ }));
17
+ const passingOutput = {
18
+ cases: [{ datasetName: 'd1', verdict: 'pass', evaluators: [] }],
19
+ summary: { total: 1, passed: 1, partial: 0, failed: 0, acceptableRate: 1 }
20
+ };
21
+ const failingOutput = {
22
+ cases: [{ datasetName: 'd1', verdict: 'fail', evaluators: [] }],
23
+ summary: { total: 1, passed: 0, partial: 0, failed: 1, acceptableRate: 0 }
24
+ };
25
+ describe('workflow test command', () => {
26
+ const exitState = { original: undefined };
27
+ beforeEach(async () => {
28
+ vi.clearAllMocks();
29
+ exitState.original = process.exitCode;
30
+ process.exitCode = undefined;
31
+ const { readAllDatasets } = await import('#services/datasets.js');
32
+ const { fetchWorkflowCatalog } = await import('#api/workflow_catalog.js');
33
+ vi.mocked(readAllDatasets).mockResolvedValue({
34
+ datasets: [{ name: 'd1', input: {}, last_output: { output: {}, date: '2026-01-01' } }],
35
+ dir: '/tmp/datasets'
36
+ });
37
+ // Catalog includes both eval names so ensureEvalWorkflowRegistered passes deterministically.
38
+ vi.mocked(fetchWorkflowCatalog).mockResolvedValue([
39
+ { name: getEvalWorkflowName('simple') },
40
+ { name: getEvalWorkflowName('my_workflow') }
41
+ ]);
42
+ });
43
+ afterEach(() => {
44
+ process.exitCode = exitState.original;
45
+ });
46
+ describe('command definition', () => {
47
+ it('enables the built-in --json flag', async () => {
48
+ const WorkflowTest = (await import('./test_eval.js')).default;
49
+ expect(WorkflowTest.enableJsonFlag).toBe(true);
50
+ });
51
+ it('binds the catalog flag to OUTPUT_CATALOG_ID', async () => {
52
+ const WorkflowTest = (await import('./test_eval.js')).default;
53
+ expect(WorkflowTest.flags).toHaveProperty('catalog');
54
+ expect(WorkflowTest.flags.catalog.env).toBe('OUTPUT_CATALOG_ID');
55
+ expect(WorkflowTest.flags.catalog.char).toBe('c');
56
+ });
57
+ });
58
+ describe('run()', () => {
59
+ const createCommand = async (jsonEnabled) => {
60
+ const WorkflowTest = (await import('./test_eval.js')).default;
61
+ const { postWorkflowRun } = await import('#api/generated/api.js');
62
+ const cmd = new WorkflowTest(['simple'], {});
63
+ cmd.log = vi.fn();
64
+ cmd.jsonEnabled = vi.fn().mockReturnValue(jsonEnabled);
65
+ cmd.parse = vi.fn().mockResolvedValue({
66
+ args: { workflowName: 'simple' },
67
+ flags: { cached: true, save: false, dataset: undefined }
68
+ });
69
+ return { cmd, postWorkflowRun: vi.mocked(postWorkflowRun) };
70
+ };
71
+ it('sets a non-zero exit code and returns the eval output when a case fails', async () => {
72
+ const { cmd, postWorkflowRun } = await createCommand(false);
73
+ postWorkflowRun.mockResolvedValue({ data: { output: failingOutput } });
74
+ const result = await cmd.run();
75
+ expect(result).toEqual(failingOutput);
76
+ expect(process.exitCode).toBe(1);
77
+ });
78
+ it('leaves the exit code at zero and returns the eval output when all cases pass', async () => {
79
+ const { cmd, postWorkflowRun } = await createCommand(false);
80
+ postWorkflowRun.mockResolvedValue({ data: { output: passingOutput } });
81
+ const result = await cmd.run();
82
+ expect(result).toEqual(passingOutput);
83
+ expect(process.exitCode).toBe(0);
84
+ });
85
+ it('renders the human-readable summary in text mode', async () => {
86
+ const { cmd, postWorkflowRun } = await createCommand(false);
87
+ postWorkflowRun.mockResolvedValue({ data: { output: passingOutput } });
88
+ await cmd.run();
89
+ const rendered = renderEvalOutput(passingOutput, getEvalWorkflowName('simple'));
90
+ expect(cmd.log).toHaveBeenCalledWith(rendered);
91
+ });
92
+ it('suppresses the rendered summary in JSON mode but still returns and sets exit code', async () => {
93
+ const { cmd, postWorkflowRun } = await createCommand(true);
94
+ postWorkflowRun.mockResolvedValue({ data: { output: failingOutput } });
95
+ const result = await cmd.run();
96
+ const rendered = renderEvalOutput(failingOutput, getEvalWorkflowName('simple'));
97
+ expect(cmd.log).not.toHaveBeenCalledWith(rendered);
98
+ expect(result).toEqual(failingOutput);
99
+ expect(process.exitCode).toBe(1);
100
+ });
101
+ it('routes registration, dataset runs, and the eval run to the resolved catalog', async () => {
102
+ const WorkflowTest = (await import('./test_eval.js')).default;
103
+ const { postWorkflowRun } = await import('#api/generated/api.js');
104
+ const { fetchWorkflowCatalog } = await import('#api/workflow_catalog.js');
105
+ const cmd = new WorkflowTest(['my_workflow'], {});
106
+ cmd.log = vi.fn();
107
+ cmd.jsonEnabled = vi.fn().mockReturnValue(false);
108
+ cmd.parse = vi.fn().mockResolvedValue({
109
+ args: { workflowName: 'my_workflow' },
110
+ flags: { catalog: 'my-catalog', cached: false, save: false, dataset: undefined }
111
+ });
112
+ vi.mocked(postWorkflowRun)
113
+ .mockResolvedValueOnce({ data: { output: {} }, status: 200, headers: new Headers() })
114
+ .mockResolvedValueOnce({ data: { output: passingOutput }, status: 200, headers: new Headers() });
115
+ await cmd.run();
116
+ expect(vi.mocked(fetchWorkflowCatalog)).toHaveBeenCalledWith('my-catalog');
117
+ expect(postWorkflowRun).toHaveBeenNthCalledWith(1, expect.objectContaining({ workflowName: 'my_workflow', catalog: 'my-catalog' }), expect.anything());
118
+ expect(postWorkflowRun).toHaveBeenNthCalledWith(2, expect.objectContaining({ workflowName: getEvalWorkflowName('my_workflow'), catalog: 'my-catalog' }), expect.anything());
119
+ });
120
+ });
121
+ });
@@ -1,3 +1,3 @@
1
1
  {
2
- "framework": "0.8.1-next.e92f632.0"
2
+ "framework": "0.8.2-dev.e78f6b4.0"
3
3
  }
@@ -2,6 +2,7 @@ import { Hook } from '@oclif/core';
2
2
  export declare const INTERACTIVE_FLAGS: string[];
3
3
  export declare const GLOBAL_FLAGS: Set<string>;
4
4
  export declare const hasInteractiveFlag: (argv: string[]) => boolean;
5
+ export declare const hasJsonFlag: (argv: string[]) => boolean;
5
6
  export declare const stripGlobalFlags: (argv: string[]) => void;
6
7
  declare const hook: Hook<'init'>;
7
8
  export default hook;
@@ -6,6 +6,10 @@ const debug = debugFactory('output-cli:init');
6
6
  export const INTERACTIVE_FLAGS = ['--yes', '--non-interactive'];
7
7
  export const GLOBAL_FLAGS = new Set(INTERACTIVE_FLAGS);
8
8
  export const hasInteractiveFlag = (argv) => argv.some(arg => INTERACTIVE_FLAGS.includes(arg));
9
+ // The version banner must never reach stdout in JSON mode, where it would
10
+ // corrupt the machine-readable output. oclif only suppresses `this.log` inside
11
+ // the command, not hook output, so we detect `--json` ourselves.
12
+ export const hasJsonFlag = (argv) => argv.includes('--json');
9
13
  export const stripGlobalFlags = (argv) => {
10
14
  const kept = argv.filter(arg => !GLOBAL_FLAGS.has(arg));
11
15
  if (kept.length !== argv.length) {
@@ -19,37 +23,43 @@ const hook = async function (opts) {
19
23
  if (interactive) {
20
24
  setNonInteractive(true);
21
25
  }
22
- try {
23
- // Only the local cache is read here; the registry roundtrip happens in a
24
- // detached child so it never delays the invoked command.
25
- const result = await readCachedResult(this.config.version, this.config.cacheDir);
26
- if (!result) {
27
- spawnBackgroundRefresh(this.config.version, this.config.cacheDir);
28
- return;
29
- }
30
- if (!result.updateAvailable) {
31
- return;
32
- }
33
- const border = ux.colorize('dim', '─'.repeat(80));
34
- const warning = ux.colorize('yellow', 'Uhoh! Your Output.ai CLI is behind!');
35
- const latestVer = ux.colorize('green', `v${result.latestVersion}`);
36
- const currentVer = ux.colorize('yellow', `v${result.currentVersion}`);
37
- const updateCmd = ux.colorize('cyan', 'npx output update');
38
- ux.stdout('');
39
- ux.stdout(border);
40
- ux.stdout('');
41
- ux.stdout(` ⚠️ ${warning}`);
42
- ux.stdout('');
43
- ux.stdout(` Latest is ${latestVer}, and you're using ${currentVer}`);
44
- ux.stdout('');
45
- ux.stdout(` Run \`${updateCmd}\` to update`);
46
- ux.stdout('');
47
- ux.stdout(border);
48
- ux.stdout('');
26
+ // Guard only the version-check IO: a broken or unreadable cache must never
27
+ // block CLI execution, so a failure is treated as "no cached result". Banner
28
+ // rendering below is intentionally left unguarded a fault there is a real
29
+ // bug that should surface, not fail dark.
30
+ const result = await readCachedResult(this.config.version, this.config.cacheDir)
31
+ .catch((error) => {
32
+ debug('Version check failed: %O', error);
33
+ return null;
34
+ });
35
+ if (!result) {
36
+ spawnBackgroundRefresh(this.config.version, this.config.cacheDir);
37
+ return;
49
38
  }
50
- catch (error) {
51
- // Never block CLI execution
52
- debug('Version banner failed: %O', error);
39
+ if (!result.updateAvailable) {
40
+ return;
53
41
  }
42
+ // Skip the banner entirely in JSON mode: even on stderr it is pure noise to
43
+ // a script consuming the command's structured output.
44
+ if (hasJsonFlag(opts.argv) || hasJsonFlag(process.argv)) {
45
+ return;
46
+ }
47
+ const border = ux.colorize('dim', '─'.repeat(80));
48
+ const warning = ux.colorize('yellow', 'Uhoh! Your Output.ai CLI is behind!');
49
+ const latestVer = ux.colorize('green', `v${result.latestVersion}`);
50
+ const currentVer = ux.colorize('yellow', `v${result.currentVersion}`);
51
+ const updateCmd = ux.colorize('cyan', 'npx output update');
52
+ // Advisory notice goes to stderr so stdout stays clean for piping in every mode.
53
+ ux.stderr('');
54
+ ux.stderr(border);
55
+ ux.stderr('');
56
+ ux.stderr(` ⚠️ ${warning}`);
57
+ ux.stderr('');
58
+ ux.stderr(` Latest is ${latestVer}, and you're using ${currentVer}`);
59
+ ux.stderr('');
60
+ ux.stderr(` Run \`${updateCmd}\` to update`);
61
+ ux.stderr('');
62
+ ux.stderr(border);
63
+ ux.stderr('');
54
64
  };
55
65
  export default hook;
@@ -12,11 +12,12 @@ vi.mock('#utils/interactive.js', () => ({
12
12
  vi.mock('@oclif/core', () => ({
13
13
  ux: {
14
14
  stdout: vi.fn(),
15
+ stderr: vi.fn(),
15
16
  colorize: vi.fn((_color, text) => text)
16
17
  }
17
18
  }));
18
19
  import { ux } from '@oclif/core';
19
- import hook, { hasInteractiveFlag, stripGlobalFlags } from './init.js';
20
+ import hook, { hasInteractiveFlag, hasJsonFlag, stripGlobalFlags } from './init.js';
20
21
  describe('init hook', () => {
21
22
  beforeEach(() => {
22
23
  vi.clearAllMocks();
@@ -24,7 +25,7 @@ describe('init hook', () => {
24
25
  const createHookContext = (version = '0.8.4') => ({
25
26
  config: { version, cacheDir: '/tmp/test-cache' }
26
27
  });
27
- it('should display warning when cached result says an update is available', async () => {
28
+ it('should display warning on stderr when cached result says an update is available', async () => {
28
29
  vi.mocked(readCachedResult).mockResolvedValue({
29
30
  updateAvailable: true,
30
31
  currentVersion: '0.8.4',
@@ -34,13 +35,25 @@ describe('init hook', () => {
34
35
  await hook.call(ctx, { argv: [], id: undefined });
35
36
  expect(readCachedResult).toHaveBeenCalledWith('0.8.4', '/tmp/test-cache');
36
37
  expect(spawnBackgroundRefresh).not.toHaveBeenCalled();
37
- expect(ux.stdout).toHaveBeenCalled();
38
- const output = vi.mocked(ux.stdout).mock.calls.map(c => c[0]).join('\n');
38
+ expect(ux.stderr).toHaveBeenCalled();
39
+ expect(ux.stdout).not.toHaveBeenCalled();
40
+ const output = vi.mocked(ux.stderr).mock.calls.map(c => c[0]).join('\n');
39
41
  expect(output).toContain('Uhoh');
40
42
  expect(output).toContain('v1.0.0');
41
43
  expect(output).toContain('v0.8.4');
42
44
  expect(output).toContain('npx output update');
43
45
  });
46
+ it('should suppress the warning entirely in JSON mode', async () => {
47
+ vi.mocked(readCachedResult).mockResolvedValue({
48
+ updateAvailable: true,
49
+ currentVersion: '0.8.4',
50
+ latestVersion: '1.0.0'
51
+ });
52
+ const ctx = createHookContext();
53
+ await hook.call(ctx, { argv: ['--json'], id: undefined });
54
+ expect(ux.stderr).not.toHaveBeenCalled();
55
+ expect(ux.stdout).not.toHaveBeenCalled();
56
+ });
44
57
  it('should not display anything when up to date', async () => {
45
58
  vi.mocked(readCachedResult).mockResolvedValue({
46
59
  updateAvailable: false,
@@ -121,6 +134,17 @@ describe('init hook', () => {
121
134
  expect(hasInteractiveFlag([])).toBe(false);
122
135
  });
123
136
  });
137
+ describe('hasJsonFlag', () => {
138
+ it('returns true when --json is present', () => {
139
+ expect(hasJsonFlag(['workflow', 'runs', 'list', '--json'])).toBe(true);
140
+ });
141
+ it('returns false when --json is absent', () => {
142
+ expect(hasJsonFlag(['workflow', 'runs', 'list', '--format', 'table'])).toBe(false);
143
+ });
144
+ it('returns false for an empty argv', () => {
145
+ expect(hasJsonFlag([])).toBe(false);
146
+ });
147
+ });
124
148
  describe('stripGlobalFlags', () => {
125
149
  it('mutates argv in place to remove global flags', () => {
126
150
  const argv = ['init', '--yes', 'foo', '--non-interactive'];
@@ -3,13 +3,13 @@ import { checkAgentStructure, prepareTemplateVariables, initializeAgentConfig, e
3
3
  import { access } from 'node:fs/promises';
4
4
  import fs from 'node:fs/promises';
5
5
  vi.mock('node:fs/promises');
6
- vi.mock('../utils/paths.js', () => ({
6
+ vi.mock('#utils/paths.js', () => ({
7
7
  getTemplateDir: vi.fn().mockReturnValue('/templates')
8
8
  }));
9
- vi.mock('../utils/template.js', () => ({
9
+ vi.mock('#utils/template.js', () => ({
10
10
  processTemplate: vi.fn().mockImplementation((content) => content)
11
11
  }));
12
- vi.mock('../utils/claude.js', () => ({
12
+ vi.mock('#utils/claude.js', () => ({
13
13
  executeClaudeCommand: vi.fn().mockResolvedValue(undefined)
14
14
  }));
15
15
  vi.mock('@oclif/core', () => ({
@@ -152,14 +152,14 @@ describe('coding_agents service', () => {
152
152
  vi.mocked(fs.writeFile).mockResolvedValue(undefined);
153
153
  });
154
154
  it('should call registerPluginMarketplace and installOutputAIPlugin', async () => {
155
- const { executeClaudeCommand } = await import('../utils/claude.js');
155
+ const { executeClaudeCommand } = await import('#utils/claude.js');
156
156
  await ensureClaudePlugin('/test/project');
157
157
  expect(executeClaudeCommand).toHaveBeenCalledWith(['plugin', 'marketplace', 'add', 'growthxai/output'], '/test/project', { ignoreFailure: true });
158
158
  expect(executeClaudeCommand).toHaveBeenCalledWith(['plugin', 'marketplace', 'update', 'outputai'], '/test/project');
159
159
  expect(executeClaudeCommand).toHaveBeenCalledWith(['plugin', 'install', 'outputai@outputai', '--scope', 'project'], '/test/project');
160
160
  });
161
161
  it('should show error and prompt user when plugin commands fail', async () => {
162
- const { executeClaudeCommand } = await import('../utils/claude.js');
162
+ const { executeClaudeCommand } = await import('#utils/claude.js');
163
163
  const { confirm } = await import('#utils/prompt.js');
164
164
  vi.mocked(executeClaudeCommand)
165
165
  .mockResolvedValueOnce(undefined) // marketplace add
@@ -171,7 +171,7 @@ describe('coding_agents service', () => {
171
171
  }));
172
172
  });
173
173
  it('should allow user to proceed without plugin setup if they confirm', async () => {
174
- const { executeClaudeCommand } = await import('../utils/claude.js');
174
+ const { executeClaudeCommand } = await import('#utils/claude.js');
175
175
  const { confirm } = await import('#utils/prompt.js');
176
176
  vi.mocked(executeClaudeCommand)
177
177
  .mockRejectedValue(new Error('All plugin commands fail'));
@@ -221,7 +221,7 @@ describe('coding_agents service', () => {
221
221
  vi.mocked(fs.writeFile).mockResolvedValue(undefined);
222
222
  });
223
223
  it('should show error and prompt user when registerPluginMarketplace fails', async () => {
224
- const { executeClaudeCommand } = await import('../utils/claude.js');
224
+ const { executeClaudeCommand } = await import('#utils/claude.js');
225
225
  const { confirm } = await import('#utils/prompt.js');
226
226
  vi.mocked(executeClaudeCommand)
227
227
  .mockResolvedValueOnce(undefined) // marketplace add
@@ -233,7 +233,7 @@ describe('coding_agents service', () => {
233
233
  }));
234
234
  });
235
235
  it('should show error and prompt user when installOutputAIPlugin fails', async () => {
236
- const { executeClaudeCommand } = await import('../utils/claude.js');
236
+ const { executeClaudeCommand } = await import('#utils/claude.js');
237
237
  const { confirm } = await import('#utils/prompt.js');
238
238
  vi.mocked(executeClaudeCommand)
239
239
  .mockResolvedValueOnce(undefined) // marketplace add
@@ -246,7 +246,7 @@ describe('coding_agents service', () => {
246
246
  }));
247
247
  });
248
248
  it('should allow user to proceed without plugin setup if they confirm', async () => {
249
- const { executeClaudeCommand } = await import('../utils/claude.js');
249
+ const { executeClaudeCommand } = await import('#utils/claude.js');
250
250
  const { confirm } = await import('#utils/prompt.js');
251
251
  vi.mocked(executeClaudeCommand)
252
252
  .mockRejectedValue(new Error('All plugin commands fail'));
@@ -256,7 +256,7 @@ describe('coding_agents service', () => {
256
256
  expect(fs.mkdir).toHaveBeenCalled();
257
257
  });
258
258
  it('should rethrow plugin error in non-interactive mode without prompting', async () => {
259
- const { executeClaudeCommand } = await import('../utils/claude.js');
259
+ const { executeClaudeCommand } = await import('#utils/claude.js');
260
260
  const { confirm } = await import('#utils/prompt.js');
261
261
  const { isInteractive } = await import('#utils/interactive.js');
262
262
  vi.mocked(isInteractive).mockReturnValueOnce(false);
@@ -6,8 +6,8 @@ export interface DatasetInfo {
6
6
  lastOutputDate?: string;
7
7
  lastEvalDate?: string;
8
8
  }
9
- export declare function resolveDatasetsDir(workflowName: string, basePath?: string): string | null;
10
- export declare function resolveDefaultDatasetsDir(workflowName: string, basePath?: string): string;
9
+ export declare function resolveDatasetsDir(workflowName: string, basePath?: string, workflowPath?: string): Promise<string | null>;
10
+ export declare function resolveDefaultDatasetsDir(workflowName: string, basePath?: string, workflowPath?: string): Promise<string>;
11
11
  export declare function readDatasetFile(filePath: string): Promise<Dataset[]>;
12
12
  export declare function readAllDatasets(workflowName: string, filterNames?: string[], basePath?: string): Promise<{
13
13
  datasets: Dataset[];
@@ -5,24 +5,26 @@ import yaml from 'js-yaml';
5
5
  import { DatasetSchema } from '@outputai/evals';
6
6
  import { getTrace } from '#services/trace_reader.js';
7
7
  import { sanitizeSecrets } from '#utils/secret_sanitizer.js';
8
+ import { resolveWorkflowDir, WORKFLOWS_PATHS } from '#utils/workflow_dir.js';
9
+ import { getWorkflowsBasePath } from '#utils/paths.js';
8
10
  const DATASETS_DIR = 'tests/datasets';
9
- const WORKFLOWS_PATHS = ['src/workflows', 'workflows'];
10
- export function resolveDatasetsDir(workflowName, basePath = process.cwd()) {
11
- for (const workflowsDir of WORKFLOWS_PATHS) {
12
- const candidate = resolve(basePath, workflowsDir, workflowName, DATASETS_DIR);
13
- if (existsSync(candidate)) {
14
- return candidate;
15
- }
11
+ export async function resolveDatasetsDir(workflowName, basePath, workflowPath) {
12
+ const workflowDir = await resolveWorkflowDir(workflowName, basePath, workflowPath);
13
+ if (!workflowDir) {
14
+ return null;
16
15
  }
17
- return null;
16
+ const datasetsDir = resolve(workflowDir, DATASETS_DIR);
17
+ return existsSync(datasetsDir) ? datasetsDir : null;
18
18
  }
19
- export function resolveDefaultDatasetsDir(workflowName, basePath = process.cwd()) {
20
- const existing = resolveDatasetsDir(workflowName, basePath);
21
- if (existing) {
22
- return existing;
19
+ export async function resolveDefaultDatasetsDir(workflowName, basePath, workflowPath) {
20
+ // Write into the resolved workflow's tests/datasets — even when it doesn't
21
+ // exist yet (first generation) — so nested workflows get the right location.
22
+ const workflowDir = await resolveWorkflowDir(workflowName, basePath, workflowPath);
23
+ if (workflowDir) {
24
+ return resolve(workflowDir, DATASETS_DIR);
23
25
  }
24
- // Default to first workflows path
25
- return resolve(basePath, WORKFLOWS_PATHS[0], workflowName, DATASETS_DIR);
26
+ // Workflow not found (unknown name / API unavailable): flat convention.
27
+ return resolve(basePath ?? getWorkflowsBasePath(), WORKFLOWS_PATHS[0], workflowName, DATASETS_DIR);
26
28
  }
27
29
  export async function readDatasetFile(filePath) {
28
30
  const raw = yaml.load(await readFile(filePath, 'utf-8'));
@@ -39,9 +41,9 @@ export async function readDatasetFile(filePath) {
39
41
  });
40
42
  }
41
43
  export async function readAllDatasets(workflowName, filterNames, basePath) {
42
- const dir = resolveDatasetsDir(workflowName, basePath);
44
+ const dir = await resolveDatasetsDir(workflowName, basePath);
43
45
  if (!dir) {
44
- return { datasets: [], dir: resolveDefaultDatasetsDir(workflowName, basePath) };
46
+ return { datasets: [], dir: await resolveDefaultDatasetsDir(workflowName, basePath) };
45
47
  }
46
48
  const files = await readdir(dir);
47
49
  const ymlFiles = files.filter(f => f.endsWith('.yml') || f.endsWith('.yaml'));
@@ -76,7 +78,7 @@ export async function writeDataset(dataset, filePath) {
76
78
  await writeFile(filePath, yaml.dump(fileObj, { lineWidth: 120, noRefs: true, sortKeys: false }), 'utf-8');
77
79
  }
78
80
  export async function listDatasets(workflowName, basePath) {
79
- const dir = resolveDatasetsDir(workflowName, basePath);
81
+ const dir = await resolveDatasetsDir(workflowName, basePath);
80
82
  if (!dir) {
81
83
  return [];
82
84
  }
@@ -1,12 +1,20 @@
1
- import { describe, it, expect, beforeEach, afterEach } from 'vitest';
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
2
  import { mkdtemp, rm, writeFile, readFile, mkdir } from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
4
  import { tmpdir } from 'node:os';
5
5
  import yaml from 'js-yaml';
6
- import { readDatasetFile, readAllDatasets, writeDataset, listDatasets } from './datasets.js';
6
+ import { readDatasetFile, readAllDatasets, writeDataset, listDatasets, resolveDefaultDatasetsDir } from './datasets.js';
7
+ import * as catalog from '#api/workflow_catalog.js';
8
+ vi.mock('#api/workflow_catalog.js', () => ({
9
+ fetchWorkflowCatalog: vi.fn()
10
+ }));
7
11
  const ctx = { tmpDir: '' };
8
12
  beforeEach(async () => {
9
13
  ctx.tmpDir = await mkdtemp(join(tmpdir(), 'output-datasets-test-'));
14
+ // Default: no API. Flat-layout cases resolve offline before this is hit;
15
+ // nested cases override with a resolved catalog.
16
+ vi.mocked(catalog.fetchWorkflowCatalog).mockReset();
17
+ vi.mocked(catalog.fetchWorkflowCatalog).mockRejectedValue(new Error('API unavailable'));
10
18
  });
11
19
  afterEach(async () => {
12
20
  await rm(ctx.tmpDir, { recursive: true, force: true });
@@ -102,6 +110,17 @@ describe('readAllDatasets', () => {
102
110
  expect(datasets).toHaveLength(2);
103
111
  expect(datasets.map(d => d.name).sort()).toEqual(['case_2', 'case_3']);
104
112
  });
113
+ it('resolves a nested workflow folder via the catalog', async () => {
114
+ const datasetsDir = join(ctx.tmpDir, 'src', 'workflows', 'a', 'b', 'c', 'tests', 'datasets');
115
+ await mkdir(datasetsDir, { recursive: true });
116
+ await writeYaml(join(datasetsDir, 'cases.yml'), {
117
+ nested_case: { input: { x: 1 } }
118
+ });
119
+ vi.mocked(catalog.fetchWorkflowCatalog).mockResolvedValue([{ name: 'a_b_c', path: '/app/dist/workflows/a/b/c/workflow.js' }]);
120
+ const { datasets, dir } = await readAllDatasets('a_b_c', undefined, ctx.tmpDir);
121
+ expect(datasets.map(d => d.name)).toEqual(['nested_case']);
122
+ expect(dir).toBe(datasetsDir);
123
+ });
105
124
  it('returns empty datasets and a default dir when workflow has no datasets dir', async () => {
106
125
  const { datasets, dir } = await readAllDatasets('nonexistent_workflow', undefined, ctx.tmpDir);
107
126
  expect(datasets).toHaveLength(0);
@@ -200,3 +219,19 @@ describe('listDatasets', () => {
200
219
  expect(infos).toHaveLength(0);
201
220
  });
202
221
  });
222
+ // ---------------------------------------------------------------------------
223
+ // resolveDefaultDatasetsDir
224
+ // ---------------------------------------------------------------------------
225
+ describe('resolveDefaultDatasetsDir', () => {
226
+ it('returns the nested workflow tests/datasets for first-time generation via catalog', async () => {
227
+ // Workflow dir exists; its tests/datasets does not yet (first generation).
228
+ await mkdir(join(ctx.tmpDir, 'src', 'workflows', 'a', 'b', 'c'), { recursive: true });
229
+ vi.mocked(catalog.fetchWorkflowCatalog).mockResolvedValue([{ name: 'a_b_c', path: '/app/dist/workflows/a/b/c/workflow.js' }]);
230
+ const dir = await resolveDefaultDatasetsDir('a_b_c', ctx.tmpDir);
231
+ expect(dir).toBe(join(ctx.tmpDir, 'src', 'workflows', 'a', 'b', 'c', 'tests', 'datasets'));
232
+ });
233
+ it('falls back to the flat convention when the workflow cannot be resolved', async () => {
234
+ const dir = await resolveDefaultDatasetsDir('unknown_flow', ctx.tmpDir);
235
+ expect(dir).toBe(join(ctx.tmpDir, 'src', 'workflows', 'unknown_flow', 'tests', 'datasets'));
236
+ });
237
+ });
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Explain why a `<wf>_eval` workflow isn't registered. When the eval source
3
+ * exists on disk but isn't in the catalog, it almost always means tests/evals
4
+ * never compiled to dist (a tsconfig exclude dropped it) — so point at that
5
+ * instead of a bare WorkflowNotFoundError.
6
+ */
7
+ export declare function diagnoseMissingEvalWorkflow(workflowName: string, basePath?: string): Promise<string>;
@@ -0,0 +1,35 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { resolve } from 'node:path';
3
+ import { getEvalWorkflowName } from '@outputai/evals';
4
+ import { resolveWorkflowDir } from '#utils/workflow_dir.js';
5
+ const EVAL_WORKFLOW_FILES = ['tests/evals/workflow.ts', 'tests/evals/workflow.js'];
6
+ /**
7
+ * Explain why a `<wf>_eval` workflow isn't registered. When the eval source
8
+ * exists on disk but isn't in the catalog, it almost always means tests/evals
9
+ * never compiled to dist (a tsconfig exclude dropped it) — so point at that
10
+ * instead of a bare WorkflowNotFoundError.
11
+ */
12
+ export async function diagnoseMissingEvalWorkflow(workflowName, basePath) {
13
+ const evalName = getEvalWorkflowName(workflowName);
14
+ const workflowDir = await resolveWorkflowDir(workflowName, basePath);
15
+ const evalSource = workflowDir ?
16
+ EVAL_WORKFLOW_FILES.map(file => resolve(workflowDir, file)).find(existsSync) :
17
+ undefined;
18
+ if (evalSource) {
19
+ return [
20
+ `Eval workflow "${evalName}" is not registered, but its source exists at:`,
21
+ ` ${evalSource}`,
22
+ '',
23
+ 'This usually means tests/evals did not compile to dist. Check your tsconfig:',
24
+ ' - Ensure tests/evals is not excluded (avoid excluding "src/**/tests").',
25
+ ' - Prefer excluding "**/*.spec.ts" and "**/*.test.ts" instead.',
26
+ '',
27
+ 'Rebuild the worker so dist/.../tests/evals/workflow.js exists, then retry.'
28
+ ].join('\n');
29
+ }
30
+ return [
31
+ `No eval workflow defined for "${workflowName}".`,
32
+ '',
33
+ `Create an eval workflow at tests/evals/workflow.ts that registers "${evalName}".`
34
+ ].join('\n');
35
+ }
@@ -1,6 +1,5 @@
1
1
  import { describe, it, expect } from 'vitest';
2
2
  import { formatWorkflowResult } from './format_workflow_result.js';
3
- import { formatOutput } from './output_formatter.js';
4
3
  describe('formatWorkflowResult', () => {
5
4
  it('should display output for completed workflows', () => {
6
5
  const result = formatWorkflowResult({
@@ -76,16 +75,4 @@ describe('formatWorkflowResult', () => {
76
75
  expect(result).toContain('Status: failed');
77
76
  expect(result).not.toContain('Error:');
78
77
  });
79
- it('should work with formatOutput for json format', () => {
80
- const data = {
81
- workflowId: 'wf-456',
82
- status: 'failed',
83
- output: null,
84
- error: 'Activity task failed'
85
- };
86
- const output = formatOutput(data, 'json', formatWorkflowResult);
87
- const parsed = JSON.parse(output);
88
- expect(parsed.status).toBe('failed');
89
- expect(parsed.error).toBe('Activity task failed');
90
- });
91
78
  });
@@ -1 +1 @@
1
- export declare function resolveInput(workflowName: string, scenario: string | undefined, inputFlag: string | undefined, commandName: string): Promise<unknown>;
1
+ export declare function resolveInput(workflowName: string, scenario: string | undefined, inputFlag: string | undefined, commandName: string, catalog?: string): Promise<unknown>;
@@ -1,7 +1,7 @@
1
1
  import { ux } from '@oclif/core';
2
2
  import { parseInputFlag } from '#utils/input_parser.js';
3
3
  import { resolveScenarioPath, getScenarioNotFoundMessage } from '#utils/scenario_resolver.js';
4
- export async function resolveInput(workflowName, scenario, inputFlag, commandName) {
4
+ export async function resolveInput(workflowName, scenario, inputFlag, commandName, catalog) {
5
5
  if (inputFlag && scenario) {
6
6
  return ux.error('Cannot use both scenario argument and --input flag. Choose one.', { exit: 1 });
7
7
  }
@@ -9,7 +9,7 @@ export async function resolveInput(workflowName, scenario, inputFlag, commandNam
9
9
  return parseInputFlag(inputFlag);
10
10
  }
11
11
  if (scenario) {
12
- const resolution = await resolveScenarioPath(workflowName, scenario);
12
+ const resolution = await resolveScenarioPath(workflowName, scenario, undefined, undefined, catalog);
13
13
  if (!resolution.found) {
14
14
  return ux.error(getScenarioNotFoundMessage(workflowName, scenario, resolution.searchedPaths), { exit: 1 });
15
15
  }
@@ -1,10 +1,9 @@
1
+ export { extractWorkflowRelativePath, findWorkflowDirectoryFromPath } from '#utils/workflow_dir.js';
1
2
  export interface ScenarioResolutionResult {
2
3
  found: boolean;
3
4
  path?: string;
4
5
  searchedPaths: string[];
5
6
  }
6
- export declare function extractWorkflowRelativePath(path: string): string | null;
7
- export declare function findWorkflowDirectoryFromPath(workflowPath: string | undefined, basePath?: string): string | null;
8
- export declare function resolveScenarioPath(workflowName: string, scenarioName: string, basePath?: string, workflowPath?: string): Promise<ScenarioResolutionResult>;
7
+ export declare function resolveScenarioPath(workflowName: string, scenarioName: string, basePath?: string, workflowPath?: string, catalog?: string): Promise<ScenarioResolutionResult>;
9
8
  export declare function listScenariosForWorkflow(workflowName: string, workflowPath?: string, basePath?: string): string[];
10
9
  export declare function getScenarioNotFoundMessage(workflowName: string, scenarioName: string, searchedPaths: string[]): string;