@tagma/sdk 0.6.12 → 0.7.1

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 (125) hide show
  1. package/README.md +56 -15
  2. package/dist/bootstrap.d.ts +6 -6
  3. package/dist/bootstrap.d.ts.map +1 -1
  4. package/dist/bootstrap.js +5 -6
  5. package/dist/bootstrap.js.map +1 -1
  6. package/dist/config.d.ts +8 -0
  7. package/dist/config.d.ts.map +1 -0
  8. package/dist/config.js +5 -0
  9. package/dist/config.js.map +1 -0
  10. package/dist/core/dataflow.d.ts +23 -0
  11. package/dist/core/dataflow.d.ts.map +1 -0
  12. package/dist/core/dataflow.js +99 -0
  13. package/dist/core/dataflow.js.map +1 -0
  14. package/dist/core/log-prune.d.ts +16 -0
  15. package/dist/core/log-prune.d.ts.map +1 -0
  16. package/dist/core/log-prune.js +34 -0
  17. package/dist/core/log-prune.js.map +1 -0
  18. package/dist/core/preflight.d.ts +13 -0
  19. package/dist/core/preflight.d.ts.map +1 -0
  20. package/dist/core/preflight.js +61 -0
  21. package/dist/core/preflight.js.map +1 -0
  22. package/dist/core/run-context.d.ts +52 -0
  23. package/dist/core/run-context.d.ts.map +1 -0
  24. package/dist/core/run-context.js +156 -0
  25. package/dist/core/run-context.js.map +1 -0
  26. package/dist/core/run-state.d.ts +25 -0
  27. package/dist/core/run-state.d.ts.map +1 -0
  28. package/dist/core/run-state.js +93 -0
  29. package/dist/core/run-state.js.map +1 -0
  30. package/dist/core/scheduler.d.ts +13 -0
  31. package/dist/core/scheduler.d.ts.map +1 -0
  32. package/dist/core/scheduler.js +35 -0
  33. package/dist/core/scheduler.js.map +1 -0
  34. package/dist/core/task-executor.d.ts +13 -0
  35. package/dist/core/task-executor.d.ts.map +1 -0
  36. package/dist/core/task-executor.js +623 -0
  37. package/dist/core/task-executor.js.map +1 -0
  38. package/dist/core/trigger-errors.d.ts +9 -0
  39. package/dist/core/trigger-errors.d.ts.map +1 -0
  40. package/dist/core/trigger-errors.js +15 -0
  41. package/dist/core/trigger-errors.js.map +1 -0
  42. package/dist/engine.d.ts +6 -14
  43. package/dist/engine.d.ts.map +1 -1
  44. package/dist/engine.js +68 -1035
  45. package/dist/engine.js.map +1 -1
  46. package/dist/index.d.ts +9 -0
  47. package/dist/index.d.ts.map +1 -0
  48. package/dist/index.js +6 -0
  49. package/dist/index.js.map +1 -0
  50. package/dist/pipeline-definition.d.ts +3 -0
  51. package/dist/pipeline-definition.d.ts.map +1 -0
  52. package/dist/pipeline-definition.js +4 -0
  53. package/dist/pipeline-definition.js.map +1 -0
  54. package/dist/pipeline-runner.d.ts +2 -1
  55. package/dist/pipeline-runner.d.ts.map +1 -1
  56. package/dist/pipeline-runner.js +2 -2
  57. package/dist/pipeline-runner.js.map +1 -1
  58. package/dist/plugins.d.ts +5 -0
  59. package/dist/plugins.d.ts.map +1 -0
  60. package/dist/plugins.js +3 -0
  61. package/dist/plugins.js.map +1 -0
  62. package/dist/ports.d.ts +4 -0
  63. package/dist/ports.d.ts.map +1 -1
  64. package/dist/ports.js +27 -4
  65. package/dist/ports.js.map +1 -1
  66. package/dist/registry.d.ts +3 -19
  67. package/dist/registry.d.ts.map +1 -1
  68. package/dist/registry.js +7 -35
  69. package/dist/registry.js.map +1 -1
  70. package/dist/tagma.d.ts +24 -0
  71. package/dist/tagma.d.ts.map +1 -0
  72. package/dist/tagma.js +23 -0
  73. package/dist/tagma.js.map +1 -0
  74. package/dist/utils-api.d.ts +2 -0
  75. package/dist/utils-api.d.ts.map +1 -0
  76. package/dist/utils-api.js +2 -0
  77. package/dist/utils-api.js.map +1 -0
  78. package/dist/validate-raw.d.ts +4 -4
  79. package/dist/validate-raw.js +91 -132
  80. package/dist/validate-raw.js.map +1 -1
  81. package/dist/yaml.d.ts +4 -0
  82. package/dist/yaml.d.ts.map +1 -0
  83. package/dist/yaml.js +3 -0
  84. package/dist/yaml.js.map +1 -0
  85. package/package.json +53 -8
  86. package/src/bootstrap.ts +6 -6
  87. package/src/config.ts +26 -0
  88. package/src/core/dataflow.test.ts +166 -0
  89. package/src/core/dataflow.ts +161 -0
  90. package/src/core/log-prune.test.ts +58 -0
  91. package/src/core/log-prune.ts +43 -0
  92. package/src/core/preflight.test.ts +49 -0
  93. package/src/core/preflight.ts +89 -0
  94. package/src/core/run-context.test.ts +244 -0
  95. package/src/core/run-context.ts +207 -0
  96. package/src/core/run-state.test.ts +98 -0
  97. package/src/core/run-state.ts +122 -0
  98. package/src/core/scheduler.test.ts +83 -0
  99. package/src/core/scheduler.ts +42 -0
  100. package/src/core/task-executor.ts +769 -0
  101. package/src/core/trigger-errors.ts +15 -0
  102. package/src/engine-ports-mixed.test.ts +68 -411
  103. package/src/engine-ports.test.ts +37 -341
  104. package/src/engine.ts +80 -1248
  105. package/src/index.ts +28 -0
  106. package/src/pipeline-definition.ts +5 -0
  107. package/src/pipeline-runner.test.ts +5 -9
  108. package/src/pipeline-runner.ts +3 -2
  109. package/src/plugin-registry.test.ts +7 -10
  110. package/src/plugins.ts +18 -0
  111. package/src/ports.test.ts +80 -0
  112. package/src/ports.ts +36 -4
  113. package/src/registry.ts +7 -49
  114. package/src/schema-ports.test.ts +41 -214
  115. package/src/tagma.test.ts +84 -0
  116. package/src/tagma.ts +47 -0
  117. package/src/utils-api.ts +8 -0
  118. package/src/validate-raw-ports.test.ts +80 -393
  119. package/src/validate-raw.ts +93 -137
  120. package/src/yaml.ts +11 -0
  121. package/dist/sdk.d.ts +0 -32
  122. package/dist/sdk.d.ts.map +0 -1
  123. package/dist/sdk.js +0 -41
  124. package/dist/sdk.js.map +0 -1
  125. package/src/sdk.ts +0 -151
@@ -0,0 +1,15 @@
1
+ export class TriggerBlockedError extends Error {
2
+ readonly code = 'TRIGGER_BLOCKED' as const;
3
+ constructor(message: string) {
4
+ super(message);
5
+ this.name = 'TriggerBlockedError';
6
+ }
7
+ }
8
+
9
+ export class TriggerTimeoutError extends Error {
10
+ readonly code = 'TRIGGER_TIMEOUT' as const;
11
+ constructor(message: string) {
12
+ super(message);
13
+ this.name = 'TriggerTimeoutError';
14
+ }
15
+ }
@@ -1,154 +1,90 @@
1
1
  import { describe, expect, test } from 'bun:test';
2
- import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
3
3
  import { tmpdir } from 'node:os';
4
4
  import { join } from 'node:path';
5
- import { PluginRegistry } from './registry';
6
5
  import { bootstrapBuiltins } from './bootstrap';
7
6
  import { runPipeline, type RunEventPayload } from './engine';
8
- import type { DriverPlugin, PipelineConfig, TaskConfig, TaskPorts, TaskStatus } from './types';
9
-
10
- // Mixed-mode port tests. Prompt Tasks do NOT declare ports — their I/O
11
- // contract is inferred from direct-neighbor Command Tasks. The three
12
- // cross-type boundaries the design has to cover:
13
- //
14
- // prompt → command (AI task produces outputs inferred from the
15
- // downstream Command's declared inputs)
16
- // command → prompt (AI task consumes the upstream Command's
17
- // declared outputs via substitution + [Inputs])
18
- // prompt → prompt (no structured port flow — free text only,
19
- // carried by continue_from / normalizedOutput)
20
- //
21
- // A mock AI driver stands in for a real LLM. It records the engine's
22
- // serialized prompt to a sidecar file and emits a per-task JSON
23
- // response on the final stdout line, simulating the `[Output Format]`
24
- // contract. Asserting on the sidecar record lets each test verify the
25
- // engine prepended the right `[Inputs]` / `[Output Format]` blocks
26
- // and expanded `{{inputs.X}}` placeholders inside the prompt.
7
+ import { PluginRegistry } from './registry';
8
+ import type { DriverPlugin, PipelineConfig, TaskConfig } from './types';
27
9
 
28
10
  const PERMS = { read: true, write: false, execute: false };
29
11
 
30
12
  function makeDir(): string {
31
- return mkdtempSync(join(tmpdir(), 'tagma-ports-mixed-'));
13
+ return mkdtempSync(join(tmpdir(), 'tagma-bindings-mixed-'));
32
14
  }
33
15
 
34
16
  function writeEmitScript(dir: string, name: string, payload: Record<string, unknown>): string {
35
17
  const path = join(dir, `${name}.js`);
36
- const src = `process.stdout.write(${JSON.stringify(JSON.stringify(payload))});\nprocess.stdout.write('\\n');\n`;
37
- writeFileSync(path, src);
18
+ writeFileSync(
19
+ path,
20
+ `process.stdout.write(${JSON.stringify(JSON.stringify(payload))});\nprocess.stdout.write('\\n');\n`,
21
+ );
38
22
  return path;
39
23
  }
40
24
 
41
- function writeEchoArgsScript(dir: string, name: string): string {
42
- const path = join(dir, `${name}.js`);
43
- const src = `process.stdout.write(process.argv.slice(2).join('|'));\nprocess.stdout.write('\\n');\n`;
44
- writeFileSync(path, src);
25
+ function writeEchoArgsScript(dir: string): string {
26
+ const path = join(dir, 'echo.js');
27
+ writeFileSync(path, `process.stdout.write(process.argv.slice(2).join('|'));\n`);
45
28
  return path;
46
29
  }
47
30
 
48
- /**
49
- * Mock-driver spawn script: read stdin (the serialized prompt), write
50
- * it to a sidecar record file, echo it to stdout, then append the
51
- * `MOCK_RESPONSE` env value as the final line — which extractTaskOutputs
52
- * picks up as the model's JSON output.
53
- */
54
31
  function writeMockDriverScript(dir: string): string {
55
32
  const path = join(dir, 'mock-driver.js');
56
- const src = [
57
- `const fs = require('fs');`,
58
- `const recordPath = process.env.MOCK_RECORD_PATH;`,
59
- `let buf = '';`,
60
- `process.stdin.setEncoding('utf8');`,
61
- `process.stdin.on('data', (c) => { buf += c; });`,
62
- `process.stdin.on('end', () => {`,
63
- ` if (recordPath) fs.writeFileSync(recordPath, buf);`,
64
- ` process.stdout.write(buf);`,
65
- ` if (!buf.endsWith('\\n')) process.stdout.write('\\n');`,
66
- ` const resp = process.env.MOCK_RESPONSE || '';`,
67
- ` if (resp) process.stdout.write(resp + '\\n');`,
68
- `});`,
69
- ].join('\n');
70
- writeFileSync(path, src);
33
+ writeFileSync(
34
+ path,
35
+ [
36
+ `const fs = require('fs');`,
37
+ `let buf = '';`,
38
+ `process.stdin.setEncoding('utf8');`,
39
+ `process.stdin.on('data', (c) => { buf += c; });`,
40
+ `process.stdin.on('end', () => {`,
41
+ ` fs.writeFileSync(process.env.MOCK_RECORD_PATH, buf);`,
42
+ ` process.stdout.write(process.env.MOCK_RESPONSE + '\\n');`,
43
+ `});`,
44
+ ].join('\n'),
45
+ );
71
46
  return path;
72
47
  }
73
48
 
74
- interface MockConfig {
75
- /** Per-task-id JSON response the mock "model" emits as its final line. */
76
- readonly responses: Readonly<Record<string, Record<string, unknown>>>;
77
- /** Per-task-id file path where the echoed prompt is recorded. */
78
- readonly records: Readonly<Record<string, string>>;
79
- }
80
-
81
- function makeMockDriver(scriptPath: string, cfg: MockConfig): DriverPlugin {
82
- return {
83
- name: 'mock-echo',
49
+ function registry(script: string, responses: Record<string, Record<string, unknown>>, records: Record<string, string>) {
50
+ const reg = new PluginRegistry();
51
+ bootstrapBuiltins(reg);
52
+ const driver: DriverPlugin = {
53
+ name: 'mock',
84
54
  capabilities: { sessionResume: false, systemPrompt: true, outputFormat: true },
85
55
  async buildCommand(task) {
86
- const env: Record<string, string> = {};
87
- const resp = cfg.responses[task.id];
88
- if (resp) env.MOCK_RESPONSE = JSON.stringify(resp);
89
- const recordPath = cfg.records[task.id];
90
- if (recordPath) env.MOCK_RECORD_PATH = recordPath;
91
56
  return {
92
- args: ['node', scriptPath],
57
+ args: ['node', script],
93
58
  stdin: task.prompt ?? '',
94
- env,
59
+ env: {
60
+ MOCK_RESPONSE: JSON.stringify(responses[task.id] ?? {}),
61
+ MOCK_RECORD_PATH: records[task.id] ?? join(process.cwd(), 'prompt.txt'),
62
+ },
95
63
  };
96
64
  },
97
65
  parseResult(stdout) {
98
- // A real AI driver strips transport chrome and returns only the
99
- // model's message here. For the mock, the entire stdout IS the
100
- // model's echo + final JSON line, so exposing it unchanged is
101
- // equivalent.
102
- return { normalizedOutput: stdout };
66
+ return { normalizedOutput: stdout.trim() };
103
67
  },
104
68
  };
105
- }
106
-
107
- function registryWithMock(scriptPath: string, cfg: MockConfig): PluginRegistry {
108
- const reg = new PluginRegistry();
109
- bootstrapBuiltins(reg);
110
- reg.registerPlugin('drivers', 'mock-echo', makeMockDriver(scriptPath, cfg));
69
+ reg.registerPlugin('drivers', 'mock', driver);
111
70
  return reg;
112
71
  }
113
72
 
114
73
  function task(overrides: Partial<TaskConfig> & { id: string }): TaskConfig {
115
- return {
116
- name: overrides.id,
117
- permissions: PERMS,
118
- driver: 'opencode',
119
- ...overrides,
120
- };
74
+ return { name: overrides.id, permissions: PERMS, driver: 'mock', ...overrides };
121
75
  }
122
76
 
123
77
  function pipeline(tasks: TaskConfig[]): PipelineConfig {
124
78
  return {
125
- name: 'ports-mixed-test',
126
- tracks: [
127
- {
128
- id: 't',
129
- name: 'T',
130
- driver: 'opencode',
131
- permissions: PERMS,
132
- on_failure: 'skip_downstream',
133
- tasks,
134
- },
135
- ],
79
+ name: 'mixed-bindings-test',
80
+ tracks: [{ id: 't', name: 'T', permissions: PERMS, driver: 'mock', tasks }],
136
81
  };
137
82
  }
138
83
 
139
- interface RunResult {
140
- events: RunEventPayload[];
141
- success: boolean;
142
- }
143
-
144
- async function run(
145
- config: PipelineConfig,
146
- workDir: string,
147
- registry: PluginRegistry,
148
- ): Promise<RunResult> {
84
+ async function run(config: PipelineConfig, workDir: string, reg: PluginRegistry) {
149
85
  const events: RunEventPayload[] = [];
150
86
  const result = await runPipeline(config, workDir, {
151
- registry,
87
+ registry: reg,
152
88
  skipPluginLoading: true,
153
89
  onEvent: (e) => events.push(e),
154
90
  });
@@ -163,335 +99,56 @@ function finalUpdateFor(events: RunEventPayload[], qid: string): RunEventPayload
163
99
  return last;
164
100
  }
165
101
 
166
- function finalStatusFrom(events: RunEventPayload[], qid: string): TaskStatus | undefined {
167
- const last = finalUpdateFor(events, qid);
168
- return last && last.type === 'task_update' ? last.status : undefined;
169
- }
170
-
171
- describe('engine — ports: mixed prompt/command combinations', () => {
172
- test('prompt → command: prompt outputs are inferred from downstream Command inputs', async () => {
173
- const dir = makeDir();
174
- try {
175
- const mockScript = writeMockDriverScript(dir);
176
- const echo = writeEchoArgsScript(dir, 'echo');
177
- const upRecord = join(dir, 'up.prompt');
178
- const responses: Record<string, Record<string, unknown>> = {
179
- up: { city: 'Shanghai', id: 7 },
180
- };
181
- const records: Record<string, string> = { up: upRecord };
182
-
183
- // `up` is a Prompt — it declares NO ports. Its output schema is
184
- // inferred at runtime from `down`'s declared inputs, which drives
185
- // the `[Output Format]` block the mock "model" sees.
186
- const config = pipeline([
187
- task({
188
- id: 'up',
189
- prompt: 'Pick a random city.',
190
- driver: 'mock-echo',
191
- }),
192
- task({
193
- id: 'down',
194
- depends_on: ['up'],
195
- command: `node "${echo}" "{{inputs.city}}" "{{inputs.id}}"`,
196
- ports: {
197
- inputs: [
198
- { name: 'city', type: 'string', required: true },
199
- { name: 'id', type: 'number', required: true },
200
- ],
201
- } as TaskPorts,
202
- }),
203
- ]);
204
-
205
- const registry = registryWithMock(mockScript, { responses, records });
206
- const { events, success } = await run(config, dir, registry);
207
- expect(success).toBe(true);
208
-
209
- // Upstream prompt was enriched with an [Output Format] block that
210
- // names the keys `down` wants (city, id) — inferred, not declared.
211
- expect(existsSync(upRecord)).toBe(true);
212
- const upPrompt = readFileSync(upRecord, 'utf8');
213
- expect(upPrompt).toContain('[Output Format]');
214
- expect(upPrompt).toContain('city');
215
- expect(upPrompt).toContain('id');
216
-
217
- // Engine extracted the mock's final-line JSON from normalizedOutput
218
- // using the inferred output schema.
219
- const upFinal = finalUpdateFor(events, 't.up')!;
220
- if (upFinal.type !== 'task_update') throw new Error('expected update');
221
- expect(upFinal.status).toBe('success');
222
- expect(upFinal.outputs).toEqual({ city: 'Shanghai', id: 7 });
223
-
224
- // Downstream command saw the values post-substitution.
225
- const downFinal = finalUpdateFor(events, 't.down')!;
226
- if (downFinal.type !== 'task_update') throw new Error('expected update');
227
- expect(downFinal.status).toBe('success');
228
- expect((downFinal.stdout ?? '').trim()).toBe('Shanghai|7');
229
- expect(downFinal.inputs).toEqual({ city: 'Shanghai', id: 7 });
230
- } finally {
231
- rmSync(dir, { recursive: true, force: true });
232
- }
233
- });
234
-
235
- test('command → prompt: prompt inputs are inferred from upstream Command outputs', async () => {
102
+ describe('engine mixed prompt/command unified bindings', () => {
103
+ test('prompt outputs are inferred from downstream command inputs', async () => {
236
104
  const dir = makeDir();
237
105
  try {
238
- const mockScript = writeMockDriverScript(dir);
239
- const emit = writeEmitScript(dir, 'emit', { city: 'Berlin', id: 3 });
240
- const downRecord = join(dir, 'down.prompt');
241
- const responses: Record<string, Record<string, unknown>> = {
242
- down: { summary: 'ok' },
243
- };
244
- const records: Record<string, string> = { down: downRecord };
245
-
246
- // `down` is a Prompt — it declares NO ports. Its input schema is
247
- // inferred from `up`'s declared outputs; its output schema is
248
- // empty (no downstream Command to infer from), so `down` is a
249
- // terminal free-text Prompt with structured inputs only.
106
+ const driverScript = writeMockDriverScript(dir);
107
+ const echo = writeEchoArgsScript(dir);
108
+ const record = join(dir, 'prompt.txt');
109
+ const reg = registry(driverScript, { plan: { city: 'Paris' } }, { plan: record });
250
110
  const config = pipeline([
111
+ task({ id: 'plan', prompt: 'Pick a city' }),
251
112
  task({
252
- id: 'up',
253
- command: `node "${emit}"`,
254
- ports: {
255
- outputs: [
256
- { name: 'city', type: 'string' },
257
- { name: 'id', type: 'number' },
258
- ],
259
- } as TaskPorts,
260
- }),
261
- task({
262
- id: 'down',
263
- depends_on: ['up'],
264
- prompt: 'City is {{inputs.city}}, id={{inputs.id}}.',
265
- driver: 'mock-echo',
113
+ id: 'fetch',
114
+ driver: 'opencode',
115
+ depends_on: ['plan'],
116
+ command: `node "${echo}" "{{inputs.city}}"`,
117
+ inputs: { city: { from: 't.plan.outputs.city', type: 'string', required: true } },
266
118
  }),
267
119
  ]);
268
120
 
269
- const registry = registryWithMock(mockScript, { responses, records });
270
- const { events, success } = await run(config, dir, registry);
121
+ const { events, success } = await run(config, dir, reg);
271
122
  expect(success).toBe(true);
272
-
273
- // Downstream prompt saw:
274
- // 1. Placeholders substituted with concrete values
275
- // 2. An [Inputs] context block listing the inferred values
276
- // 3. NO [Output Format] block (no downstream Command to infer
277
- // an output contract from — the Prompt is terminal)
278
- const downPrompt = readFileSync(downRecord, 'utf8');
279
- expect(downPrompt).toContain('City is Berlin, id=3.');
280
- expect(downPrompt).toContain('[Inputs]');
281
- expect(downPrompt).toMatch(/city:\s*"Berlin"/);
282
- expect(downPrompt).toMatch(/id:\s*3\b/);
283
- expect(downPrompt).not.toContain('[Output Format]');
284
-
285
- const downFinal = finalUpdateFor(events, 't.down')!;
286
- if (downFinal.type !== 'task_update') throw new Error('expected update');
287
- expect(downFinal.inputs).toEqual({ city: 'Berlin', id: 3 });
288
- // No downstream Command → no inferred outputs → outputs stay null.
289
- expect(downFinal.outputs).toBeFalsy();
123
+ expect(readFileSync(record, 'utf8')).toContain('[Output Format]');
124
+ expect(finalUpdateFor(events, 't.plan')?.outputs).toEqual({ city: 'Paris' });
125
+ expect(finalUpdateFor(events, 't.fetch')?.inputs).toEqual({ city: 'Paris' });
290
126
  } finally {
291
127
  rmSync(dir, { recursive: true, force: true });
292
128
  }
293
129
  });
294
130
 
295
- test('command → prompt command: prompt relays structured data both directions', async () => {
131
+ test('prompt inputs are inferred from upstream command outputs', async () => {
296
132
  const dir = makeDir();
297
133
  try {
298
- const mockScript = writeMockDriverScript(dir);
299
- const emit = writeEmitScript(dir, 'emit', { city: 'Paris' });
300
- const echo = writeEchoArgsScript(dir, 'echo');
301
- const midRecord = join(dir, 'mid.prompt');
302
- const responses: Record<string, Record<string, unknown>> = {
303
- mid: { greeting: 'Bonjour Paris' },
304
- };
305
- const records: Record<string, string> = { mid: midRecord };
306
-
307
- // `mid` is a Prompt between two Commands. Its inferred inputs
308
- // come from `up` (city), its inferred outputs come from `down`
309
- // (greeting). No ports declared on `mid`.
134
+ const emit = writeEmitScript(dir, 'emit', { city: 'Berlin' });
135
+ const driverScript = writeMockDriverScript(dir);
136
+ const record = join(dir, 'prompt.txt');
137
+ const reg = registry(driverScript, { summarize: {} }, { summarize: record });
310
138
  const config = pipeline([
311
139
  task({
312
140
  id: 'up',
141
+ driver: 'opencode',
313
142
  command: `node "${emit}"`,
314
- ports: { outputs: [{ name: 'city', type: 'string' }] } as TaskPorts,
315
- }),
316
- task({
317
- id: 'mid',
318
- depends_on: ['up'],
319
- prompt: 'Generate a greeting for {{inputs.city}}.',
320
- driver: 'mock-echo',
321
- }),
322
- task({
323
- id: 'down',
324
- depends_on: ['mid'],
325
- command: `node "${echo}" "{{inputs.greeting}}"`,
326
- ports: {
327
- inputs: [{ name: 'greeting', type: 'string', required: true }],
328
- } as TaskPorts,
329
- }),
330
- ]);
331
-
332
- const registry = registryWithMock(mockScript, { responses, records });
333
- const { events, success } = await run(config, dir, registry);
334
- expect(success).toBe(true);
335
-
336
- // Middle prompt has both [Inputs] (from upstream) and
337
- // [Output Format] (from downstream) — inferred in both directions.
338
- const midPrompt = readFileSync(midRecord, 'utf8');
339
- expect(midPrompt).toContain('[Inputs]');
340
- expect(midPrompt).toMatch(/city:\s*"Paris"/);
341
- expect(midPrompt).toContain('[Output Format]');
342
- expect(midPrompt).toContain('greeting');
343
- expect(midPrompt).toContain('Generate a greeting for Paris.');
344
-
345
- const midFinal = finalUpdateFor(events, 't.mid')!;
346
- if (midFinal.type !== 'task_update') throw new Error('expected update');
347
- expect(midFinal.inputs).toEqual({ city: 'Paris' });
348
- expect(midFinal.outputs).toEqual({ greeting: 'Bonjour Paris' });
349
-
350
- const downFinal = finalUpdateFor(events, 't.down')!;
351
- if (downFinal.type !== 'task_update') throw new Error('expected update');
352
- expect((downFinal.stdout ?? '').trim()).toBe('Bonjour Paris');
353
- } finally {
354
- rmSync(dir, { recursive: true, force: true });
355
- }
356
- });
357
-
358
- test('prompt → prompt: no structured port flow, free text only', async () => {
359
- const dir = makeDir();
360
- try {
361
- const mockScript = writeMockDriverScript(dir);
362
- const downRecord = join(dir, 'down.prompt');
363
- const responses: Record<string, Record<string, unknown>> = {
364
- up: { city: 'Tokyo' },
365
- down: { greeting: 'hello Tokyo' },
366
- };
367
- const records: Record<string, string> = { down: downRecord };
368
-
369
- // Neither Prompt has a Command neighbor in either direction, so
370
- // both have empty inferred ports. `up`'s JSON final line is NOT
371
- // extracted (no inferred outputs); `down` does NOT see `[Inputs]`
372
- // or `[Output Format]`. Information between them flows only
373
- // through continue_from / free text — and the downstream's
374
- // `{{inputs.city}}` is an author error the engine logs as
375
- // "placeholder rendered empty".
376
- const config = pipeline([
377
- task({
378
- id: 'up',
379
- prompt: 'Pick a city.',
380
- driver: 'mock-echo',
381
- }),
382
- task({
383
- id: 'down',
384
- depends_on: ['up'],
385
- prompt: 'Greet the city.',
386
- driver: 'mock-echo',
143
+ outputs: { city: { type: 'string' } },
387
144
  }),
145
+ task({ id: 'summarize', depends_on: ['up'], prompt: 'City is {{inputs.city}}' }),
388
146
  ]);
389
147
 
390
- const registry = registryWithMock(mockScript, { responses, records });
391
- const { events, success } = await run(config, dir, registry);
148
+ const { events, success } = await run(config, dir, reg);
392
149
  expect(success).toBe(true);
393
- expect(finalStatusFrom(events, 't.up')).toBe('success');
394
- expect(finalStatusFrom(events, 't.down')).toBe('success');
395
-
396
- // No inferred outputs on either side.
397
- const upFinal = finalUpdateFor(events, 't.up')!;
398
- if (upFinal.type !== 'task_update') throw new Error('expected update');
399
- expect(upFinal.outputs).toBeFalsy();
400
-
401
- // Down's prompt has no [Inputs] / [Output Format] blocks.
402
- const downPrompt = readFileSync(downRecord, 'utf8');
403
- expect(downPrompt).not.toContain('[Inputs]');
404
- expect(downPrompt).not.toContain('[Output Format]');
405
-
406
- const downFinal = finalUpdateFor(events, 't.down')!;
407
- if (downFinal.type !== 'task_update') throw new Error('expected update');
408
- expect(downFinal.inputs).toEqual({});
409
- expect(downFinal.outputs).toBeFalsy();
410
- } finally {
411
- rmSync(dir, { recursive: true, force: true });
412
- }
413
- });
414
-
415
- test('prompt with two upstream Commands exporting the same name → blocked', async () => {
416
- const dir = makeDir();
417
- try {
418
- const mockScript = writeMockDriverScript(dir);
419
- const emitA = writeEmitScript(dir, 'emitA', { val: 'from-a' });
420
- const emitB = writeEmitScript(dir, 'emitB', { val: 'from-b' });
421
- const responses: Record<string, Record<string, unknown>> = {};
422
- const records: Record<string, string> = {};
423
-
424
- const config = pipeline([
425
- task({
426
- id: 'a',
427
- command: `node "${emitA}"`,
428
- ports: { outputs: [{ name: 'val', type: 'string' }] } as TaskPorts,
429
- }),
430
- task({
431
- id: 'b',
432
- command: `node "${emitB}"`,
433
- ports: { outputs: [{ name: 'val', type: 'string' }] } as TaskPorts,
434
- }),
435
- task({
436
- id: 'down',
437
- depends_on: ['a', 'b'],
438
- prompt: 'Use {{inputs.val}}',
439
- driver: 'mock-echo',
440
- }),
441
- ]);
442
-
443
- const registry = registryWithMock(mockScript, { responses, records });
444
- const { events } = await run(config, dir, registry);
445
- expect(finalStatusFrom(events, 't.down')).toBe('blocked');
446
- const downFinal = finalUpdateFor(events, 't.down');
447
- if (downFinal?.type === 'task_update') {
448
- expect(downFinal.stderr ?? '').toMatch(/cannot disambiguate|produced by multiple upstream/i);
449
- }
450
- } finally {
451
- rmSync(dir, { recursive: true, force: true });
452
- }
453
- });
454
-
455
- test('prompt with two downstream Commands disagreeing on input type → blocked', async () => {
456
- const dir = makeDir();
457
- try {
458
- const mockScript = writeMockDriverScript(dir);
459
- const echo1 = writeEchoArgsScript(dir, 'echo1');
460
- const echo2 = writeEchoArgsScript(dir, 'echo2');
461
- const responses: Record<string, Record<string, unknown>> = {};
462
- const records: Record<string, string> = {};
463
-
464
- const config = pipeline([
465
- task({
466
- id: 'mid',
467
- prompt: 'produce a date',
468
- driver: 'mock-echo',
469
- }),
470
- task({
471
- id: 'd1',
472
- depends_on: ['mid'],
473
- command: `node "${echo1}" "{{inputs.date}}"`,
474
- ports: {
475
- inputs: [{ name: 'date', type: 'string', required: true }],
476
- } as TaskPorts,
477
- }),
478
- task({
479
- id: 'd2',
480
- depends_on: ['mid'],
481
- command: `node "${echo2}" "{{inputs.date}}"`,
482
- ports: {
483
- inputs: [{ name: 'date', type: 'number', required: true }],
484
- } as TaskPorts,
485
- }),
486
- ]);
487
-
488
- const registry = registryWithMock(mockScript, { responses, records });
489
- const { events } = await run(config, dir, registry);
490
- expect(finalStatusFrom(events, 't.mid')).toBe('blocked');
491
- const midFinal = finalUpdateFor(events, 't.mid');
492
- if (midFinal?.type === 'task_update') {
493
- expect(midFinal.stderr ?? '').toMatch(/conflicting type requirements|conflicting output/i);
494
- }
150
+ expect(readFileSync(record, 'utf8')).toContain('City is Berlin');
151
+ expect(finalUpdateFor(events, 't.summarize')?.inputs).toEqual({ city: 'Berlin' });
495
152
  } finally {
496
153
  rmSync(dir, { recursive: true, force: true });
497
154
  }