@tagma/sdk 0.7.0 → 0.7.3
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.
- package/README.md +84 -44
- package/dist/bootstrap.d.ts +20 -0
- package/dist/bootstrap.d.ts.map +1 -1
- package/dist/bootstrap.js +21 -11
- package/dist/bootstrap.js.map +1 -1
- package/dist/core/dataflow.d.ts.map +1 -1
- package/dist/core/dataflow.js +45 -9
- package/dist/core/dataflow.js.map +1 -1
- package/dist/core/run-context.d.ts +3 -0
- package/dist/core/run-context.d.ts.map +1 -1
- package/dist/core/run-context.js +2 -0
- package/dist/core/run-context.js.map +1 -1
- package/dist/core/task-executor.d.ts.map +1 -1
- package/dist/core/task-executor.js +46 -84
- package/dist/core/task-executor.js.map +1 -1
- package/dist/engine.d.ts +6 -0
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +3 -0
- package/dist/engine.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/plugins.d.ts +2 -2
- package/dist/plugins.d.ts.map +1 -1
- package/dist/ports.d.ts +4 -0
- package/dist/ports.d.ts.map +1 -1
- package/dist/ports.js +27 -4
- package/dist/ports.js.map +1 -1
- package/dist/registry.d.ts +10 -4
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +64 -25
- package/dist/registry.js.map +1 -1
- package/dist/runtime.d.ts +9 -0
- package/dist/runtime.d.ts.map +1 -0
- package/dist/runtime.js +8 -0
- package/dist/runtime.js.map +1 -0
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +1 -7
- package/dist/schema.js.map +1 -1
- package/dist/tagma.d.ts +11 -1
- package/dist/tagma.d.ts.map +1 -1
- package/dist/tagma.js +6 -0
- package/dist/tagma.js.map +1 -1
- package/dist/validate-raw.d.ts +4 -4
- package/dist/validate-raw.d.ts.map +1 -1
- package/dist/validate-raw.js +89 -230
- package/dist/validate-raw.js.map +1 -1
- package/package.json +2 -2
- package/src/bootstrap.ts +23 -14
- package/src/core/dataflow.test.ts +8 -9
- package/src/core/dataflow.ts +57 -14
- package/src/core/run-context.test.ts +12 -0
- package/src/core/run-context.ts +4 -0
- package/src/core/task-executor.ts +75 -135
- package/src/engine-ports-mixed.test.ts +68 -411
- package/src/engine-ports.test.ts +37 -341
- package/src/engine.ts +8 -0
- package/src/index.ts +5 -0
- package/src/pipeline-runner.test.ts +5 -9
- package/src/plugin-registry.test.ts +138 -1
- package/src/plugins.ts +5 -2
- package/src/ports.test.ts +80 -0
- package/src/ports.ts +36 -4
- package/src/registry.ts +81 -26
- package/src/runtime.ts +20 -0
- package/src/schema-ports.test.ts +47 -197
- package/src/schema.ts +1 -7
- package/src/tagma.test.ts +72 -1
- package/src/tagma.ts +16 -1
- package/src/validate-raw-ports.test.ts +80 -393
- package/src/validate-raw.ts +90 -250
|
@@ -1,154 +1,90 @@
|
|
|
1
1
|
import { describe, expect, test } from 'bun:test';
|
|
2
|
-
import {
|
|
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
|
|
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-
|
|
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
|
-
|
|
37
|
-
|
|
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
|
|
42
|
-
const path = join(dir,
|
|
43
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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',
|
|
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
|
-
|
|
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: '
|
|
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
|
-
|
|
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
|
-
|
|
167
|
-
|
|
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
|
|
239
|
-
const
|
|
240
|
-
const
|
|
241
|
-
const
|
|
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: '
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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
|
|
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
|
-
|
|
274
|
-
|
|
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('
|
|
131
|
+
test('prompt inputs are inferred from upstream command outputs', async () => {
|
|
296
132
|
const dir = makeDir();
|
|
297
133
|
try {
|
|
298
|
-
const
|
|
299
|
-
const
|
|
300
|
-
const
|
|
301
|
-
const
|
|
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
|
-
|
|
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
|
|
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(
|
|
394
|
-
expect(
|
|
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
|
}
|