@tagma/sdk 0.6.4 → 0.6.6

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 (57) hide show
  1. package/README.md +74 -6
  2. package/dist/engine.d.ts.map +1 -1
  3. package/dist/engine.js +194 -21
  4. package/dist/engine.js.map +1 -1
  5. package/dist/pipeline-runner.d.ts.map +1 -1
  6. package/dist/pipeline-runner.js +3 -0
  7. package/dist/pipeline-runner.js.map +1 -1
  8. package/dist/ports.d.ts +118 -0
  9. package/dist/ports.d.ts.map +1 -0
  10. package/dist/ports.js +365 -0
  11. package/dist/ports.js.map +1 -0
  12. package/dist/prompt-doc.d.ts +35 -1
  13. package/dist/prompt-doc.d.ts.map +1 -1
  14. package/dist/prompt-doc.js +110 -0
  15. package/dist/prompt-doc.js.map +1 -1
  16. package/dist/runner.d.ts +17 -0
  17. package/dist/runner.d.ts.map +1 -1
  18. package/dist/runner.js +171 -8
  19. package/dist/runner.js.map +1 -1
  20. package/dist/schema.d.ts.map +1 -1
  21. package/dist/schema.js +8 -0
  22. package/dist/schema.js.map +1 -1
  23. package/dist/sdk.d.ts +3 -1
  24. package/dist/sdk.d.ts.map +1 -1
  25. package/dist/sdk.js +5 -1
  26. package/dist/sdk.js.map +1 -1
  27. package/dist/validate-raw.d.ts.map +1 -1
  28. package/dist/validate-raw.js +141 -0
  29. package/dist/validate-raw.js.map +1 -1
  30. package/package.json +2 -7
  31. package/src/dag.test.ts +56 -0
  32. package/src/engine-ports.test.ts +404 -0
  33. package/src/engine.ts +231 -24
  34. package/src/pipeline-runner.ts +3 -0
  35. package/src/ports.test.ts +301 -0
  36. package/src/ports.ts +442 -0
  37. package/src/prompt-doc.test.ts +174 -0
  38. package/src/prompt-doc.ts +121 -1
  39. package/src/runner.test.ts +142 -0
  40. package/src/runner.ts +198 -8
  41. package/src/schema-ports.test.ts +236 -0
  42. package/src/schema.ts +8 -0
  43. package/src/sdk.ts +14 -0
  44. package/src/validate-raw-ports.test.ts +198 -0
  45. package/src/validate-raw.ts +155 -1
  46. package/dist/plugin-registry.test.d.ts +0 -2
  47. package/dist/plugin-registry.test.d.ts.map +0 -1
  48. package/dist/plugin-registry.test.js +0 -188
  49. package/dist/plugin-registry.test.js.map +0 -1
  50. package/dist/schema.test.d.ts +0 -2
  51. package/dist/schema.test.d.ts.map +0 -1
  52. package/dist/schema.test.js +0 -94
  53. package/dist/schema.test.js.map +0 -1
  54. package/dist/task-ref.test.d.ts +0 -2
  55. package/dist/task-ref.test.d.ts.map +0 -1
  56. package/dist/task-ref.test.js +0 -364
  57. package/dist/task-ref.test.js.map +0 -1
package/src/ports.ts ADDED
@@ -0,0 +1,442 @@
1
+ // ═══ Task ports: substitute / resolve / extract ═══
2
+ //
3
+ // One module, three concerns, all keyed on `task.ports`:
4
+ //
5
+ // 1. `substituteInputs(text, inputs)` — expand `{{inputs.<name>}}` in
6
+ // user-authored strings (command lines, prompts). Strict syntax, no
7
+ // arbitrary expressions — the placeholder is a thin pasteboard, not
8
+ // a templating engine. Unknown / undefined references render as empty
9
+ // string with a diagnostic that the caller can surface.
10
+ //
11
+ // 2. `resolveTaskInputs(task, upstreamOutputs, dependsOn)` — gather the
12
+ // values a task will consume from its direct upstreams. Matches by
13
+ // port name (or by explicit `from:`), applies defaults, coerces to
14
+ // the declared type, and classifies the result as ready / missing
15
+ // required / ambiguous. The engine calls this before a task starts
16
+ // and uses the classification to decide whether to block.
17
+ //
18
+ // 3. `extractTaskOutputs(ports, stdout, normalizedOutput)` — after a
19
+ // task succeeds, pull the declared output values from the task's
20
+ // output stream. Default strategy: find the last non-empty line that
21
+ // parses as a JSON object, and read each declared output name from
22
+ // it. Prefer `normalizedOutput` for AI tasks, fall back to raw
23
+ // stdout — command tasks only ever have stdout.
24
+ //
25
+ // Everything here is pure / deterministic so it can be reused by the CLI,
26
+ // the editor (for preview/simulation), and the engine without side effects.
27
+
28
+ import type { PortDef, TaskConfig, TaskPorts } from './types';
29
+
30
+ // ─── Template substitution ────────────────────────────────────────────
31
+
32
+ /**
33
+ * Matches `{{inputs.<identifier>}}` with optional whitespace inside the
34
+ * braces. The identifier is restricted to the same character set we use
35
+ * for task IDs (letter/underscore, then letters/digits/underscores) so
36
+ * accidental use of `{{inputs.foo.bar}}` fails loudly rather than
37
+ * silently producing garbage.
38
+ */
39
+ const PLACEHOLDER_RE = /\{\{\s*inputs\.([A-Za-z_][A-Za-z0-9_]*)\s*\}\}/g;
40
+
41
+ /**
42
+ * Scan `text` for every `{{inputs.<name>}}` placeholder and return the
43
+ * set of referenced input names. Useful at validation time: the editor
44
+ * can cross-check that each placeholder has a corresponding declared
45
+ * port and flag typos before a run ever starts.
46
+ */
47
+ export function extractInputReferences(text: string): string[] {
48
+ const names = new Set<string>();
49
+ for (const match of text.matchAll(PLACEHOLDER_RE)) {
50
+ names.add(match[1]!);
51
+ }
52
+ return [...names];
53
+ }
54
+
55
+ export interface SubstituteResult {
56
+ readonly text: string;
57
+ /** Port names that appeared in placeholders but weren't in `inputs`. */
58
+ readonly unresolved: readonly string[];
59
+ }
60
+
61
+ /**
62
+ * Replace `{{inputs.<name>}}` placeholders in `text` with values from
63
+ * `inputs`. Coercion:
64
+ * - string → as-is
65
+ * - number / boolean → `String(value)`
66
+ * - null / undefined → empty string (name is also reported as unresolved)
67
+ * - anything else (object, array, json port) → `JSON.stringify(value)`
68
+ *
69
+ * Values are substituted *verbatim* — quoting is the user's
70
+ * responsibility in the authored text. For command lines that interpolate
71
+ * user-provided strings, authors should wrap the placeholder in quotes:
72
+ *
73
+ * weather.sh --city "{{inputs.city}}"
74
+ *
75
+ * That's a documented contract rather than a silent shell-escape, because
76
+ * silent escaping would hide the difference between `--city Shanghai` and
77
+ * `--flag $(echo pwned)` — both valid command fragments, one a bug, one a
78
+ * feature. Users know which they want; the engine doesn't.
79
+ */
80
+ export function substituteInputs(
81
+ text: string,
82
+ inputs: Readonly<Record<string, unknown>>,
83
+ ): SubstituteResult {
84
+ const unresolved = new Set<string>();
85
+ const out = text.replace(PLACEHOLDER_RE, (_full, name: string) => {
86
+ if (!(name in inputs)) {
87
+ unresolved.add(name);
88
+ return '';
89
+ }
90
+ const value = inputs[name];
91
+ if (value === null || value === undefined) {
92
+ unresolved.add(name);
93
+ return '';
94
+ }
95
+ if (typeof value === 'string') return value;
96
+ if (typeof value === 'number' || typeof value === 'boolean') return String(value);
97
+ try {
98
+ return JSON.stringify(value);
99
+ } catch {
100
+ // Circular / unserializable — render a placeholder rather than
101
+ // throwing, and mark it unresolved so the caller can warn.
102
+ unresolved.add(name);
103
+ return '';
104
+ }
105
+ });
106
+ return { text: out, unresolved: [...unresolved] };
107
+ }
108
+
109
+ // ─── Input resolution ─────────────────────────────────────────────────
110
+
111
+ export type InputResolution =
112
+ | {
113
+ readonly kind: 'ready';
114
+ readonly inputs: Readonly<Record<string, unknown>>;
115
+ /**
116
+ * Optional inputs that had no upstream producer and no default;
117
+ * they are absent from `inputs` (so placeholders render empty).
118
+ * Separate from `missingRequired` so the engine can log softly
119
+ * without blocking the task.
120
+ */
121
+ readonly missingOptional: readonly string[];
122
+ }
123
+ | {
124
+ readonly kind: 'blocked';
125
+ /** Required port names that could not be satisfied. */
126
+ readonly missingRequired: readonly string[];
127
+ /** Port names with multiple ambiguous producers. */
128
+ readonly ambiguous: readonly { port: string; producers: readonly string[] }[];
129
+ /** Port names whose resolved value failed type coercion. */
130
+ readonly typeErrors: readonly { port: string; reason: string }[];
131
+ /** Human-readable multi-line description for the engine to log. */
132
+ readonly reason: string;
133
+ };
134
+
135
+ /**
136
+ * Resolve the input values for `task` from the outputs its direct
137
+ * upstreams produced.
138
+ *
139
+ * `upstreamOutputs` is keyed by fully-qualified task id and maps to the
140
+ * outputs that task published (its `TaskResult.outputs`). `dependsOn` is
141
+ * the already-qualified dependency list (from `DagNode.dependsOn`). When
142
+ * an upstream has no outputs entry (e.g. it didn't declare any or it
143
+ * failed), its entry may be missing — we just skip it during matching.
144
+ *
145
+ * Matching rules:
146
+ * - If the input port has `from: "taskId.portName"` → look up that
147
+ * specific upstream / port. Missing = unsatisfied.
148
+ * - If it has `from: "portName"` (bare) → treat as explicit port name
149
+ * but allow any upstream to provide it (useful when the user wants
150
+ * to match by name but still be explicit about the intent).
151
+ * - If no `from` → scan every upstream's outputs for a key matching
152
+ * the input name. Zero hits = unsatisfied; 2+ hits across different
153
+ * upstreams = ambiguous.
154
+ *
155
+ * The function never throws on config errors — every failure mode maps
156
+ * to a field of the `blocked` result so the engine can log a unified
157
+ * message and mark the task blocked.
158
+ */
159
+ export function resolveTaskInputs(
160
+ task: TaskConfig,
161
+ upstreamOutputs: ReadonlyMap<string, Readonly<Record<string, unknown>>>,
162
+ dependsOn: readonly string[],
163
+ ): InputResolution {
164
+ const inputsDecl = task.ports?.inputs;
165
+ if (!inputsDecl || inputsDecl.length === 0) {
166
+ return { kind: 'ready', inputs: {}, missingOptional: [] };
167
+ }
168
+
169
+ const inputs: Record<string, unknown> = {};
170
+ const missingRequired: string[] = [];
171
+ const missingOptional: string[] = [];
172
+ const ambiguous: { port: string; producers: string[] }[] = [];
173
+ const typeErrors: { port: string; reason: string }[] = [];
174
+
175
+ for (const port of inputsDecl) {
176
+ const found = findUpstreamValue(port, upstreamOutputs, dependsOn);
177
+ if (found.kind === 'ambiguous') {
178
+ ambiguous.push({ port: port.name, producers: found.producers });
179
+ continue;
180
+ }
181
+ let value: unknown;
182
+ let present = false;
183
+ if (found.kind === 'hit') {
184
+ value = found.value;
185
+ present = true;
186
+ } else if (port.default !== undefined) {
187
+ value = port.default;
188
+ present = true;
189
+ }
190
+
191
+ if (!present) {
192
+ if (port.required === true) {
193
+ missingRequired.push(port.name);
194
+ } else {
195
+ missingOptional.push(port.name);
196
+ }
197
+ continue;
198
+ }
199
+
200
+ const coerced = coerceValue(port, value);
201
+ if (coerced.kind === 'error') {
202
+ typeErrors.push({ port: port.name, reason: coerced.reason });
203
+ continue;
204
+ }
205
+ inputs[port.name] = coerced.value;
206
+ }
207
+
208
+ if (missingRequired.length > 0 || ambiguous.length > 0 || typeErrors.length > 0) {
209
+ const lines: string[] = [];
210
+ if (missingRequired.length > 0) {
211
+ lines.push(`missing required input(s): ${missingRequired.join(', ')}`);
212
+ }
213
+ if (ambiguous.length > 0) {
214
+ for (const amb of ambiguous) {
215
+ lines.push(
216
+ `input "${amb.port}" is produced by multiple upstreams ` +
217
+ `(${amb.producers.join(', ')}) — disambiguate with "from: taskId.${amb.port}"`,
218
+ );
219
+ }
220
+ }
221
+ if (typeErrors.length > 0) {
222
+ for (const te of typeErrors) {
223
+ lines.push(`input "${te.port}": ${te.reason}`);
224
+ }
225
+ }
226
+ return {
227
+ kind: 'blocked',
228
+ missingRequired,
229
+ ambiguous,
230
+ typeErrors,
231
+ reason: lines.join('\n'),
232
+ };
233
+ }
234
+
235
+ return { kind: 'ready', inputs, missingOptional };
236
+ }
237
+
238
+ type UpstreamLookup =
239
+ | { kind: 'hit'; producer: string; value: unknown }
240
+ | { kind: 'miss' }
241
+ | { kind: 'ambiguous'; producers: string[] };
242
+
243
+ function findUpstreamValue(
244
+ port: PortDef,
245
+ upstreamOutputs: ReadonlyMap<string, Readonly<Record<string, unknown>>>,
246
+ dependsOn: readonly string[],
247
+ ): UpstreamLookup {
248
+ // Explicit fully-qualified binding: "taskId.portName"
249
+ if (port.from && port.from.includes('.')) {
250
+ const dot = port.from.lastIndexOf('.');
251
+ const upstreamId = port.from.slice(0, dot);
252
+ const portName = port.from.slice(dot + 1);
253
+ const upstream = upstreamOutputs.get(upstreamId);
254
+ if (upstream && portName in upstream) {
255
+ return { kind: 'hit', producer: upstreamId, value: upstream[portName] };
256
+ }
257
+ return { kind: 'miss' };
258
+ }
259
+
260
+ // Name match (either explicit `from: "portName"` or defaulted to port.name)
261
+ const key = port.from ?? port.name;
262
+ const hits: { producer: string; value: unknown }[] = [];
263
+ for (const upstreamId of dependsOn) {
264
+ const upstream = upstreamOutputs.get(upstreamId);
265
+ if (upstream && key in upstream) {
266
+ hits.push({ producer: upstreamId, value: upstream[key] });
267
+ }
268
+ }
269
+ if (hits.length === 0) return { kind: 'miss' };
270
+ if (hits.length === 1) return { kind: 'hit', producer: hits[0]!.producer, value: hits[0]!.value };
271
+ return { kind: 'ambiguous', producers: hits.map((h) => h.producer) };
272
+ }
273
+
274
+ // ─── Type coercion ────────────────────────────────────────────────────
275
+
276
+ type Coercion = { kind: 'ok'; value: unknown } | { kind: 'error'; reason: string };
277
+
278
+ function coerceValue(port: PortDef, raw: unknown): Coercion {
279
+ switch (port.type) {
280
+ case 'string': {
281
+ if (typeof raw === 'string') return { kind: 'ok', value: raw };
282
+ if (typeof raw === 'number' || typeof raw === 'boolean') {
283
+ return { kind: 'ok', value: String(raw) };
284
+ }
285
+ return { kind: 'error', reason: `expected string, got ${describe(raw)}` };
286
+ }
287
+ case 'number': {
288
+ if (typeof raw === 'number' && Number.isFinite(raw)) return { kind: 'ok', value: raw };
289
+ if (typeof raw === 'string' && raw.trim() !== '') {
290
+ const n = Number(raw);
291
+ if (Number.isFinite(n)) return { kind: 'ok', value: n };
292
+ }
293
+ return { kind: 'error', reason: `expected number, got ${describe(raw)}` };
294
+ }
295
+ case 'boolean': {
296
+ if (typeof raw === 'boolean') return { kind: 'ok', value: raw };
297
+ if (raw === 'true' || raw === 'false') return { kind: 'ok', value: raw === 'true' };
298
+ return { kind: 'error', reason: `expected boolean, got ${describe(raw)}` };
299
+ }
300
+ case 'enum': {
301
+ const allowed = port.enum ?? [];
302
+ if (allowed.length === 0) {
303
+ return { kind: 'error', reason: 'enum port declared without "enum" values' };
304
+ }
305
+ const asStr = typeof raw === 'string' ? raw : String(raw);
306
+ if (!allowed.includes(asStr)) {
307
+ return {
308
+ kind: 'error',
309
+ reason: `value ${JSON.stringify(raw)} not in enum [${allowed.map((v) => JSON.stringify(v)).join(', ')}]`,
310
+ };
311
+ }
312
+ return { kind: 'ok', value: asStr };
313
+ }
314
+ case 'json':
315
+ // 'json' accepts anything that survives JSON round-trip. We don't
316
+ // validate deeply — users opt into `json` precisely because they
317
+ // want a free-form payload.
318
+ return { kind: 'ok', value: raw };
319
+ default: {
320
+ // Exhaustiveness — TypeScript won't let us reach here unless a
321
+ // new PortType is added without updating this switch. The return
322
+ // satisfies the type checker; in practice the default branch is
323
+ // dead code.
324
+ const _exhaustive: never = port.type;
325
+ void _exhaustive;
326
+ return { kind: 'error', reason: `unknown port type "${String(port.type)}"` };
327
+ }
328
+ }
329
+ }
330
+
331
+ function describe(v: unknown): string {
332
+ if (v === null) return 'null';
333
+ if (Array.isArray(v)) return 'array';
334
+ return typeof v;
335
+ }
336
+
337
+ // ─── Output extraction ────────────────────────────────────────────────
338
+
339
+ export interface ExtractResult {
340
+ /** Coerced values keyed by port name. Ports that failed to resolve are absent. */
341
+ readonly outputs: Readonly<Record<string, unknown>>;
342
+ /**
343
+ * Human-readable diagnostic describing what went wrong, if anything.
344
+ * `null` when every declared output was resolved cleanly. The engine
345
+ * appends this to stderr so the pipeline log explains why downstream
346
+ * inputs are missing.
347
+ */
348
+ readonly diagnostic: string | null;
349
+ }
350
+
351
+ /**
352
+ * Extract declared outputs from a terminated task's output streams.
353
+ *
354
+ * Strategy (v1 — intentionally dumb but predictable):
355
+ * 1. Prefer `normalizedOutput` when provided (AI drivers populate this
356
+ * with the canonical assistant message; it's much cleaner than raw
357
+ * stdout, which often has JSONL event dumps). Fall back to stdout
358
+ * otherwise.
359
+ * 2. Scan from the end for the first non-empty line. If it parses as a
360
+ * JSON object, use that as the source record.
361
+ * 3. If (2) fails, try parsing the entire source as JSON (supports
362
+ * commands that pretty-print with line breaks).
363
+ * 4. For each declared output port, read the matching key and coerce
364
+ * to the declared type. Coercion failures produce a diagnostic and
365
+ * the port is absent from `outputs` (treated as missing downstream).
366
+ *
367
+ * When no declared outputs are present this returns an empty `outputs`
368
+ * map and null diagnostic — the engine interprets that as "task has no
369
+ * port contract".
370
+ */
371
+ export function extractTaskOutputs(
372
+ ports: TaskPorts | undefined,
373
+ stdout: string,
374
+ normalizedOutput: string | null,
375
+ ): ExtractResult {
376
+ const decl = ports?.outputs;
377
+ if (!decl || decl.length === 0) {
378
+ return { outputs: {}, diagnostic: null };
379
+ }
380
+
381
+ const source = (normalizedOutput ?? '').length > 0 ? normalizedOutput! : stdout;
382
+ const record = parseJsonTail(source);
383
+ if (record === null) {
384
+ return {
385
+ outputs: {},
386
+ diagnostic:
387
+ 'outputs: could not find a final-line JSON object in task output — declared outputs are unresolved',
388
+ };
389
+ }
390
+
391
+ const outputs: Record<string, unknown> = {};
392
+ const warnings: string[] = [];
393
+ for (const port of decl) {
394
+ if (!(port.name in record)) {
395
+ warnings.push(`missing key "${port.name}"`);
396
+ continue;
397
+ }
398
+ const coerced = coerceValue(port, record[port.name]);
399
+ if (coerced.kind === 'error') {
400
+ warnings.push(`"${port.name}": ${coerced.reason}`);
401
+ continue;
402
+ }
403
+ outputs[port.name] = coerced.value;
404
+ }
405
+
406
+ const diagnostic = warnings.length > 0 ? `outputs: ${warnings.join('; ')}` : null;
407
+ return { outputs, diagnostic };
408
+ }
409
+
410
+ /**
411
+ * Find the last non-empty line that parses as a JSON object. Returns
412
+ * null when no such line exists. Also tries the whole source as a
413
+ * fallback — covers pretty-printed JSON that spans multiple lines.
414
+ */
415
+ function parseJsonTail(source: string): Record<string, unknown> | null {
416
+ const lines = source.split(/\r?\n/);
417
+ for (let i = lines.length - 1; i >= 0; i--) {
418
+ const line = lines[i]!.trim();
419
+ if (!line) continue;
420
+ const parsed = safeParseJson(line);
421
+ if (parsed !== null) return parsed;
422
+ // First non-empty line from the tail — if it didn't parse, fall through
423
+ // to the whole-source attempt below rather than scanning further up
424
+ // (otherwise a prior human-readable line would be silently picked up
425
+ // if it happened to contain `{...}` fragments).
426
+ break;
427
+ }
428
+ return safeParseJson(source.trim());
429
+ }
430
+
431
+ function safeParseJson(candidate: string): Record<string, unknown> | null {
432
+ if (!candidate.startsWith('{')) return null;
433
+ try {
434
+ const parsed = JSON.parse(candidate);
435
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
436
+ return parsed as Record<string, unknown>;
437
+ }
438
+ } catch {
439
+ /* not JSON */
440
+ }
441
+ return null;
442
+ }
@@ -0,0 +1,174 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import {
3
+ appendContext,
4
+ prependContext,
5
+ promptDocumentFromString,
6
+ renderInputsBlock,
7
+ renderOutputSchemaBlock,
8
+ serializePromptDocument,
9
+ } from './prompt-doc';
10
+ import type { PortDef, PromptContextBlock, PromptDocument } from './types';
11
+
12
+ // ─── renderInputsBlock ────────────────────────────────────────────────
13
+
14
+ describe('renderInputsBlock', () => {
15
+ test('returns null when no inputs declared', () => {
16
+ expect(renderInputsBlock(undefined, {})).toBeNull();
17
+ expect(renderInputsBlock([], { any: 'x' })).toBeNull();
18
+ });
19
+
20
+ test('returns null when declared inputs have no resolved values', () => {
21
+ const ports: PortDef[] = [{ name: 'city', type: 'string' }];
22
+ // values missing entirely — block is noise, omit it
23
+ expect(renderInputsBlock(ports, {})).toBeNull();
24
+ });
25
+
26
+ test('renders name: value per declared input', () => {
27
+ const ports: PortDef[] = [
28
+ { name: 'city', type: 'string' },
29
+ { name: 'id', type: 'number' },
30
+ ];
31
+ const block = renderInputsBlock(ports, { city: 'Shanghai', id: 42 });
32
+ expect(block).not.toBeNull();
33
+ expect(block!.label).toBe('Inputs');
34
+ expect(block!.content).toBe('city: "Shanghai"\nid: 42');
35
+ });
36
+
37
+ test('appends # description comment when provided', () => {
38
+ const ports: PortDef[] = [
39
+ { name: 'city', type: 'string', description: 'Target city' },
40
+ ];
41
+ const block = renderInputsBlock(ports, { city: 'Shanghai' })!;
42
+ expect(block.content).toBe('city: "Shanghai" # Target city');
43
+ });
44
+
45
+ test('preserves declaration order, not input-map iteration order', () => {
46
+ const ports: PortDef[] = [
47
+ { name: 'b', type: 'string' },
48
+ { name: 'a', type: 'string' },
49
+ ];
50
+ // Values object has 'a' first, 'b' second — block should still emit 'b' first.
51
+ const block = renderInputsBlock(ports, { a: 'x', b: 'y' })!;
52
+ expect(block.content).toBe('b: "y"\na: "x"');
53
+ });
54
+
55
+ test('skips ports whose values were not resolved', () => {
56
+ const ports: PortDef[] = [
57
+ { name: 'a', type: 'string' },
58
+ { name: 'b', type: 'string' },
59
+ ];
60
+ const block = renderInputsBlock(ports, { a: 'x' })!;
61
+ expect(block.content).toBe('a: "x"');
62
+ });
63
+
64
+ test('JSON-encodes non-primitive values', () => {
65
+ const ports: PortDef[] = [{ name: 'payload', type: 'json' }];
66
+ const block = renderInputsBlock(ports, { payload: { a: 1, b: [2, 3] } })!;
67
+ expect(block.content).toBe('payload: {"a":1,"b":[2,3]}');
68
+ });
69
+
70
+ test('booleans render verbatim, not quoted', () => {
71
+ const ports: PortDef[] = [{ name: 'flag', type: 'boolean' }];
72
+ const block = renderInputsBlock(ports, { flag: true })!;
73
+ expect(block.content).toBe('flag: true');
74
+ });
75
+ });
76
+
77
+ // ─── renderOutputSchemaBlock ──────────────────────────────────────────
78
+
79
+ describe('renderOutputSchemaBlock', () => {
80
+ test('returns null when no outputs declared', () => {
81
+ expect(renderOutputSchemaBlock(undefined)).toBeNull();
82
+ expect(renderOutputSchemaBlock([])).toBeNull();
83
+ });
84
+
85
+ test('instructs the model to emit final-line JSON', () => {
86
+ const ports: PortDef[] = [{ name: 'city', type: 'string' }];
87
+ const block = renderOutputSchemaBlock(ports)!;
88
+ expect(block.label).toBe('Output Format');
89
+ expect(block.content).toMatch(/final line/i);
90
+ });
91
+
92
+ test('lists each port with its type', () => {
93
+ const ports: PortDef[] = [
94
+ { name: 'city', type: 'string', description: 'Target city' },
95
+ { name: 'temp', type: 'number' },
96
+ ];
97
+ const block = renderOutputSchemaBlock(ports)!;
98
+ expect(block.content).toContain('- city (string): Target city');
99
+ expect(block.content).toContain('- temp (number)');
100
+ });
101
+
102
+ test('includes enum values in the type hint', () => {
103
+ const ports: PortDef[] = [
104
+ { name: 'color', type: 'enum', enum: ['red', 'green', 'blue'] },
105
+ ];
106
+ const block = renderOutputSchemaBlock(ports)!;
107
+ expect(block.content).toContain('color (enum (one of: "red", "green", "blue"))');
108
+ });
109
+
110
+ test('example object uses declared defaults when present', () => {
111
+ const ports: PortDef[] = [
112
+ { name: 'score', type: 'number', default: 0.5 },
113
+ { name: 'note', type: 'string', default: 'n/a' },
114
+ ];
115
+ const block = renderOutputSchemaBlock(ports)!;
116
+ // The example line is `Example final line: {"score":0.5,"note":"n/a"}`.
117
+ expect(block.content).toContain('"score":0.5');
118
+ expect(block.content).toContain('"note":"n/a"');
119
+ });
120
+
121
+ test('example uses type-appropriate placeholders when no default', () => {
122
+ const ports: PortDef[] = [
123
+ { name: 's', type: 'string' },
124
+ { name: 'n', type: 'number' },
125
+ { name: 'b', type: 'boolean' },
126
+ { name: 'j', type: 'json' },
127
+ ];
128
+ const block = renderOutputSchemaBlock(ports)!;
129
+ expect(block.content).toContain('"s":"..."');
130
+ expect(block.content).toContain('"n":0');
131
+ expect(block.content).toContain('"b":false');
132
+ expect(block.content).toContain('"j":null');
133
+ });
134
+
135
+ test('example uses first enum value when present', () => {
136
+ const ports: PortDef[] = [
137
+ { name: 'tier', type: 'enum', enum: ['low', 'high'] },
138
+ ];
139
+ const block = renderOutputSchemaBlock(ports)!;
140
+ expect(block.content).toContain('"tier":"low"');
141
+ });
142
+ });
143
+
144
+ // ─── prependContext / appendContext ──────────────────────────────────
145
+
146
+ describe('prependContext / appendContext', () => {
147
+ const block: PromptContextBlock = { label: 'X', content: 'x' };
148
+
149
+ test('prependContext puts block at front without mutating input', () => {
150
+ const doc: PromptDocument = { contexts: [{ label: 'Y', content: 'y' }], task: 't' };
151
+ const next = prependContext(doc, block);
152
+ expect(next.contexts.map((c) => c.label)).toEqual(['X', 'Y']);
153
+ // Original untouched — immutability is part of the contract for
154
+ // middleware safety (the engine compares doc identity to detect
155
+ // changes in some paths).
156
+ expect(doc.contexts).toHaveLength(1);
157
+ expect(doc.contexts[0]!.label).toBe('Y');
158
+ expect(next.task).toBe('t');
159
+ });
160
+
161
+ test('appendContext puts block at end without mutating input', () => {
162
+ const doc: PromptDocument = { contexts: [{ label: 'Y', content: 'y' }], task: 't' };
163
+ const next = appendContext(doc, block);
164
+ expect(next.contexts.map((c) => c.label)).toEqual(['Y', 'X']);
165
+ expect(doc.contexts).toHaveLength(1);
166
+ });
167
+
168
+ test('prepend + serialize produces [X] block above the task', () => {
169
+ const doc = promptDocumentFromString('do the thing');
170
+ const next = prependContext(doc, { label: 'Inputs', content: 'city: "Shanghai"' });
171
+ const text = serializePromptDocument(next);
172
+ expect(text).toBe('[Inputs]\ncity: "Shanghai"\n\ndo the thing');
173
+ });
174
+ });