|
6 | 6 | resolveNodeOutputField, |
7 | 7 | similarNodeIds, |
8 | 8 | } from './output-ref'; |
| 9 | +import { buildTruncationMarker, hasTruncationMarker } from './utils/output-truncation'; |
9 | 10 | import type { NodeOutput } from './schemas'; |
10 | 11 |
|
11 | 12 | function completed( |
@@ -110,6 +111,106 @@ describe('resolveNodeOutputField — declared-schema producer', () => { |
110 | 111 | const r = resolveNodeOutputField(completed('{"type":"BUG"}', undefined, ['type']), 'n', 'type'); |
111 | 112 | expect(r).toEqual({ kind: 'value', value: 'BUG' }); |
112 | 113 | }); |
| 114 | + |
| 115 | + // #2456 — a declared schema must never be QUIETER than no schema at all. Before this, |
| 116 | + // an unparseable output returned empty here while the schemaless path threw, so |
| 117 | + // declaring output_format on a `workflow:` node (whose child output is never |
| 118 | + // validated) silently turned every declared field into ''. |
| 119 | + it('unparseable output → throws, exactly like the schemaless path (#2456)', () => { |
| 120 | + const broken = completed('I could not produce JSON, sorry.', undefined, declared); |
| 121 | + expect(() => resolveNodeOutputField(broken, 'n', 'type')).toThrow(OutputRefError); |
| 122 | + try { |
| 123 | + resolveNodeOutputField(broken, 'n', 'type'); |
| 124 | + } catch (e) { |
| 125 | + expect((e as OutputRefError).reason).toBe('unparseable'); |
| 126 | + } |
| 127 | + }); |
| 128 | + |
| 129 | + it('declaring a schema is never quieter than declaring none (#2456)', () => { |
| 130 | + const text = 'not json at all'; |
| 131 | + const withSchema = (): unknown => |
| 132 | + resolveNodeOutputField(completed(text, undefined, ['f']), 'n', 'f'); |
| 133 | + const withoutSchema = (): unknown => resolveNodeOutputField(completed(text), 'n', 'f'); |
| 134 | + // Both throw, and for the same reason — that symmetry IS the contract. |
| 135 | + expect(withSchema).toThrow(OutputRefError); |
| 136 | + expect(withoutSchema).toThrow(OutputRefError); |
| 137 | + const reasonOf = (fn: () => unknown): string | undefined => { |
| 138 | + try { |
| 139 | + fn(); |
| 140 | + } catch (e) { |
| 141 | + return (e as OutputRefError).reason; |
| 142 | + } |
| 143 | + return undefined; |
| 144 | + }; |
| 145 | + expect(reasonOf(withSchema)).toBe(reasonOf(withoutSchema)); |
| 146 | + }); |
| 147 | + |
| 148 | + // The leniency that SURVIVES: a declared-optional field missing from a payload that |
| 149 | + // genuinely parsed. Only "no parseable object at all" changed. |
| 150 | + it('still lenient for a missing key inside a parsed object (#2456 scope guard)', () => { |
| 151 | + const r = resolveNodeOutputField(completed('{"type":"BUG"}', undefined, declared), 'n', 'note'); |
| 152 | + expect(r).toEqual({ kind: 'empty' }); |
| 153 | + }); |
| 154 | +}); |
| 155 | + |
| 156 | +/** |
| 157 | + * Clipped-on-persist output parses no better than prose, but the author needs the |
| 158 | + * opposite advice: the producer was right and a RESUMED run is reading the clipped |
| 159 | + * copy. `output_format` lives on `dagNodeBaseSchema`, so a bash node can declare one |
| 160 | + * — and bash stdout is the thing the event cap clips. |
| 161 | + */ |
| 162 | +describe('resolveNodeOutputField — output clipped before persistence', () => { |
| 163 | + /** What `getDagResumeSnapshot` hands back for a bash node that exceeded the cap. */ |
| 164 | + function clipped(payload: string): string { |
| 165 | + return payload.slice(0, 40) + buildTruncationMarker(Buffer.byteLength(payload)); |
| 166 | + } |
| 167 | + |
| 168 | + const bigPayload = JSON.stringify({ verdict: 'pass', blob: 'x'.repeat(40_000) }); |
| 169 | + |
| 170 | + it('reports truncated, not unparseable, on the declared-schema path', () => { |
| 171 | + const node = completed(clipped(bigPayload), undefined, ['verdict', 'blob']); |
| 172 | + try { |
| 173 | + resolveNodeOutputField(node, 'gen', 'verdict'); |
| 174 | + throw new Error('expected a throw'); |
| 175 | + } catch (e) { |
| 176 | + expect(e).toBeInstanceOf(OutputRefError); |
| 177 | + expect((e as OutputRefError).reason).toBe('truncated'); |
| 178 | + } |
| 179 | + }); |
| 180 | + |
| 181 | + it('reports truncated on the schemaless path too — both paths stay symmetric', () => { |
| 182 | + try { |
| 183 | + resolveNodeOutputField(completed(clipped(bigPayload)), 'gen', 'verdict'); |
| 184 | + throw new Error('expected a throw'); |
| 185 | + } catch (e) { |
| 186 | + expect((e as OutputRefError).reason).toBe('truncated'); |
| 187 | + } |
| 188 | + }); |
| 189 | + |
| 190 | + it('does not blame truncation for output that merely mentions it', () => { |
| 191 | + // The marker is anchored, so prose quoting the phrase mid-string is still a |
| 192 | + // plain producer error — otherwise this branch would misdiagnose in reverse. |
| 193 | + const prose = 'the log said … [truncated; original output was 5 bytes] and then stopped'; |
| 194 | + try { |
| 195 | + resolveNodeOutputField(completed(prose, undefined, ['verdict']), 'gen', 'verdict'); |
| 196 | + throw new Error('expected a throw'); |
| 197 | + } catch (e) { |
| 198 | + expect((e as OutputRefError).reason).toBe('unparseable'); |
| 199 | + } |
| 200 | + }); |
| 201 | + |
| 202 | + it('says the producer was probably right, and points at the artifacts dir', () => { |
| 203 | + const err = new OutputRefError('gen', 'verdict', 'truncated'); |
| 204 | + expect(err.message).toContain('clipped'); |
| 205 | + expect(err.message).toContain('$ARTIFACTS_DIR'); |
| 206 | + // The old advice was actively wrong here — the node DID emit the field. |
| 207 | + expect(err.message).not.toContain('Emit JSON containing'); |
| 208 | + }); |
| 209 | + |
| 210 | + it('marker round-trips through build/detect', () => { |
| 211 | + expect(hasTruncationMarker(`head${buildTruncationMarker(1234)}`)).toBe(true); |
| 212 | + expect(hasTruncationMarker('no marker here')).toBe(false); |
| 213 | + }); |
113 | 214 | }); |
114 | 215 |
|
115 | 216 | describe('resolveNodeOutputField — structuredOutput without a declared schema (lenient)', () => { |
|
0 commit comments