@jigging/agent-method 0.0.0 → 0.1.0-alpha.4

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 (52) hide show
  1. package/AGENTS.md +98 -0
  2. package/FLOW.contract.json +241 -0
  3. package/FLOW.meta.json +8 -0
  4. package/FLOW.ts +3 -0
  5. package/LICENSE +373 -0
  6. package/README.md +369 -0
  7. package/THIRD_PARTY_NOTICES +9 -0
  8. package/contracts/acp-public-updates.json +75 -0
  9. package/contracts/agent-commands.json +88 -0
  10. package/contracts/agent-replies.json +202 -0
  11. package/contracts/http-request/contract.json +37 -0
  12. package/dist/api.d.ts +11 -0
  13. package/dist/api.js +164 -0
  14. package/dist/conversation.d.ts +68 -0
  15. package/dist/conversation.js +346 -0
  16. package/dist/errors.d.ts +5 -0
  17. package/dist/errors.js +8 -0
  18. package/dist/flow.d.ts +3 -0
  19. package/dist/flow.js +2784 -0
  20. package/dist/index.d.ts +67 -0
  21. package/dist/index.js +220 -0
  22. package/dist/json.d.ts +21 -0
  23. package/dist/json.js +409 -0
  24. package/dist/schema.d.ts +7 -0
  25. package/dist/schema.js +180 -0
  26. package/dist/skills.d.ts +3 -0
  27. package/dist/skills.js +132 -0
  28. package/dist/values.d.ts +11 -0
  29. package/dist/values.js +65 -0
  30. package/justfile +32 -0
  31. package/licenses/flow.LICENSE +202 -0
  32. package/package.json +45 -5
  33. package/settings.schema.json +12 -0
  34. package/skills/answer-check/SKILL.md +5 -0
  35. package/src/api.ts +191 -0
  36. package/src/conversation.ts +387 -0
  37. package/src/errors.ts +11 -0
  38. package/src/flow.ts +66 -0
  39. package/src/index.ts +325 -0
  40. package/src/json.ts +406 -0
  41. package/src/schema.ts +230 -0
  42. package/src/skills.ts +138 -0
  43. package/src/values.ts +77 -0
  44. package/test/api.test.ts +326 -0
  45. package/test/conversation-fixture.ts +73 -0
  46. package/test/conversation.test.ts +328 -0
  47. package/test/json.test.ts +88 -0
  48. package/test/method.test.ts +308 -0
  49. package/test/pack.test.ts +81 -0
  50. package/test/result.test.ts +103 -0
  51. package/test/skills-flow.test.ts +252 -0
  52. package/tsconfig.json +17 -0
@@ -0,0 +1,308 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+
3
+ import {
4
+ AgentMethodError,
5
+ assertResponseSchema,
6
+ finishAgent,
7
+ type JsonObject,
8
+ prepareAgent,
9
+ projectResponseSchema,
10
+ } from '../src/index.js'
11
+
12
+ const closedObject = (properties: JsonObject): JsonObject => ({
13
+ type: 'object',
14
+ properties,
15
+ required: Object.keys(properties),
16
+ additionalProperties: false,
17
+ })
18
+ const schema = (properties: JsonObject): JsonObject => ({
19
+ $schema: 'https://flow.jig.md/schemas/schema-0.json',
20
+ ...closedObject(properties),
21
+ })
22
+ const answerSchema = schema({ answer: { type: 'string', enum: ['READY'] } })
23
+ const exchange = (text: string, stop = 'end-turn') => ({ outcome: 'done', output: { text, stop } })
24
+
25
+ describe('shared Agent method', () => {
26
+ test('validates session intent as frozen metadata without changing the model prompt', () => {
27
+ const reference = '013579ab-cdef-4567-89ab-0123456789ab'
28
+ const ordinary = prepareAgent({ instructions: 'Answer.' })
29
+ for (const session of [
30
+ { retain: true } as const,
31
+ { retain: true, lifetime: 'run' } as const,
32
+ { restore: reference },
33
+ ]) {
34
+ const prepared = prepareAgent({ instructions: 'Answer.', session })
35
+ expect(prepared.request).toEqual(ordinary.request)
36
+ expect(prepared.session).toEqual(session)
37
+ expect(prepared.session).not.toBe(session)
38
+ expect(Object.isFrozen(prepared.session)).toBe(true)
39
+ expect(finishAgent(prepared, exchange('answer'))).toEqual({
40
+ outcome: 'done',
41
+ output: { text: 'answer' },
42
+ })
43
+ }
44
+ expect(ordinary).not.toHaveProperty('session')
45
+ for (const session of [
46
+ null,
47
+ {},
48
+ { retain: false },
49
+ { retain: true, lifetime: 'forever' },
50
+ { restore: reference, lifetime: 'run' },
51
+ { restore: '' },
52
+ { restore: reference.toUpperCase() },
53
+ { restore: `${reference}\n` },
54
+ { retain: true, restore: reference },
55
+ { restore: reference, extra: true },
56
+ { reference },
57
+ ]) {
58
+ expect(() => prepareAgent({ instructions: 'Answer.', session } as never)).toThrow(
59
+ AgentMethodError,
60
+ )
61
+ expect(() => finishAgent({ ...ordinary, session } as never, exchange('answer'))).toThrow(
62
+ AgentMethodError,
63
+ )
64
+ }
65
+ })
66
+
67
+ test('renders canonically sorted Skills and explicitly ordered separate guidance', () => {
68
+ const makeSkill = (name: string) => ({
69
+ name,
70
+ files: [
71
+ { path: 'z.txt', text: 'z' },
72
+ { path: 'SKILL.md', text: 'guide' },
73
+ ],
74
+ })
75
+ const input = {
76
+ instructions: 'Answer.',
77
+ guidance: [
78
+ { label: 'z', text: 'first' },
79
+ { label: 'a', text: 'second' },
80
+ ],
81
+ }
82
+ const first = prepareAgent(input, [makeSkill('z'), makeSkill('a')])
83
+ const second = prepareAgent(input, [makeSkill('a'), makeSkill('z')])
84
+ expect(first.request.prompt).toBe(second.request.prompt)
85
+ const payload = JSON.parse(first.request.prompt.split('\n').at(-1)!)
86
+ expect(payload.skills.map((skill: { name: string }) => skill.name)).toEqual(['a', 'z'])
87
+ expect(payload.skills[0].files.map((file: { path: string }) => file.path)).toEqual([
88
+ 'SKILL.md',
89
+ 'z.txt',
90
+ ])
91
+ expect(payload.guidance.map((item: { label: string }) => item.label)).toEqual(['z', 'a'])
92
+ })
93
+
94
+ test('takes immutable snapshots while keeping preparation clonable and inspectable', () => {
95
+ const mutable = JSON.parse(JSON.stringify(answerSchema))
96
+ const prepared = prepareAgent({ instructions: 'Answer.', responseSchema: mutable })
97
+ mutable.properties.answer.enum = ['WRONG']
98
+ expect(Object.isFrozen(prepared.request.responseSchema)).toBe(true)
99
+ const result = finishAgent(prepared, exchange('{"answer":"READY"}'))
100
+ expect(result).toEqual({
101
+ outcome: 'done',
102
+ output: { text: '{"answer":"READY"}', structured: { answer: 'READY' } },
103
+ })
104
+ expect(Object.isFrozen(result.output)).toBe(true)
105
+ const cloned = JSON.parse(JSON.stringify(prepared))
106
+ cloned.request.prompt += '\nCheck once more.'
107
+ expect(finishAgent(cloned, exchange('{"answer":"READY"}'))).toEqual(result)
108
+ expect(finishAgent({ ...prepared }, exchange('{"answer":"READY"}'))).toEqual(result)
109
+ expect(() => finishAgent({ request: { prompt: '' } }, exchange('anything'))).toThrow(
110
+ AgentMethodError,
111
+ )
112
+ })
113
+
114
+ test('projects schema without mutating or sharing nested values', () => {
115
+ const projected = projectResponseSchema(answerSchema)
116
+ expect(projected.$schema).toBeUndefined()
117
+ expect(answerSchema.$schema).toBe('https://flow.jig.md/schemas/schema-0.json')
118
+ expect(projected.properties).not.toBe(answerSchema.properties)
119
+ })
120
+
121
+ test('maps complete transport outcomes and preserves partial text', () => {
122
+ const prepared = prepareAgent({ instructions: 'Answer.' })
123
+ expect(finishAgent(prepared, exchange('yes')).outcome).toBe('done')
124
+ expect(finishAgent(prepared, exchange('no', 'refusal'))).toEqual({
125
+ outcome: 'blocked',
126
+ output: { text: 'no' },
127
+ })
128
+ expect(finishAgent(prepared, exchange('partial', 'limit'))).toEqual({
129
+ outcome: 'limit',
130
+ output: { text: 'partial' },
131
+ })
132
+ const structured = prepareAgent({ instructions: 'Answer.', responseSchema: answerSchema })
133
+ expect(finishAgent(structured, exchange('partial', 'limit')).output).toEqual({
134
+ text: 'partial',
135
+ })
136
+ expect(
137
+ finishAgent(structured, exchange('{"answer":"READY"}', 'refusal')).output.structured,
138
+ ).toEqual({ answer: 'READY' })
139
+ expect(() => finishAgent(structured, exchange('{"answer":"WRONG"}', 'limit'))).toThrow()
140
+ })
141
+
142
+ test('accepts raw JSON and one complete JSON fence; rejects prose and duplicate members', () => {
143
+ const prepared = prepareAgent({ instructions: 'Answer.', responseSchema: answerSchema })
144
+ for (const text of ['{"answer":"READY"}', '\n```json\r\n{"answer":"READY"}\r\n```\n']) {
145
+ expect(finishAgent(prepared, exchange(text)).output.structured).toEqual({ answer: 'READY' })
146
+ }
147
+ for (const text of [
148
+ 'Here: {"answer":"READY"}',
149
+ '```\n{"answer":"READY"}\n```',
150
+ '{"answer":"READY","answer":"READY"}',
151
+ '```json\n{"answer":"READY"}\n```\ntrailer',
152
+ ]) {
153
+ expect(() => finishAgent(prepared, exchange(text))).toThrow(AgentMethodError)
154
+ }
155
+ })
156
+
157
+ test('rejects invalid shape, schema mismatch and malformed Exchange facts', () => {
158
+ const prepared = prepareAgent({ instructions: 'Answer.', responseSchema: answerSchema })
159
+ for (const text of ['{}', '{"answer":"WRONG"}', '{"answer":"READY","extra":1}', '[]', 'null']) {
160
+ expect(() => finishAgent(prepared, exchange(text))).toThrow()
161
+ }
162
+ for (const result of [
163
+ { outcome: 'blocked', output: { text: 'no', stop: 'refusal' } },
164
+ exchange('yes', 'output-limit'),
165
+ { outcome: 'done', output: { text: 'yes', stop: ['end-turn'] } },
166
+ { ...exchange('yes'), extra: true },
167
+ { outcome: 'done', output: { text: 'yes', stop: 'end-turn', structured: {} } },
168
+ ]) {
169
+ expect(() => finishAgent(prepared, result)).toThrow(AgentMethodError)
170
+ }
171
+ })
172
+
173
+ test('rejects duplicate names/paths/labels, escapes and non-JSON inputs', () => {
174
+ const skill = { name: 'answer', files: [{ path: 'SKILL.md', text: 'check' }] }
175
+ expect(() => prepareAgent({ instructions: 'a' }, [skill, skill])).toThrow()
176
+ expect(() =>
177
+ prepareAgent({ instructions: 'a' }, [{ ...skill, files: [...skill.files, ...skill.files] }]),
178
+ ).toThrow()
179
+ expect(() =>
180
+ prepareAgent({ instructions: 'a' }, [
181
+ { ...skill, files: [{ path: '../SKILL.md', text: '' }] },
182
+ ]),
183
+ ).toThrow()
184
+ expect(() =>
185
+ prepareAgent({
186
+ instructions: 'a',
187
+ guidance: [
188
+ { label: 'same', text: '1' },
189
+ { label: 'same', text: '2' },
190
+ ],
191
+ }),
192
+ ).toThrow()
193
+ expect(() => prepareAgent({ instructions: '\ud800' })).toThrow()
194
+ let getterCalled = false
195
+ expect(() =>
196
+ prepareAgent({
197
+ get instructions() {
198
+ getterCalled = true
199
+ return 'a'
200
+ },
201
+ }),
202
+ ).toThrow()
203
+ expect(getterCalled).toBe(false)
204
+ })
205
+
206
+ test('enforces combined group, item, content and rendered prompt limits', () => {
207
+ const guidance = Array.from({ length: 64 }, (_, i) => ({ label: `label-${i}`, text: '' }))
208
+ expect(() =>
209
+ prepareAgent({ instructions: 'a', guidance }, [
210
+ { name: 's', files: [{ path: 'SKILL.md', text: '' }] },
211
+ ]),
212
+ ).toThrow()
213
+ const files = Array.from({ length: 1024 }, (_, i) => ({
214
+ path: i === 0 ? 'SKILL.md' : `file-${i}`,
215
+ text: '',
216
+ }))
217
+ expect(() =>
218
+ prepareAgent({ instructions: 'a', guidance: [{ label: 'g', text: '' }] }, [
219
+ { name: 's', files },
220
+ ]),
221
+ ).toThrow()
222
+ expect(() => prepareAgent({ instructions: 'é'.repeat(524_289) })).toThrow()
223
+ expect(() => prepareAgent({ instructions: 'a'.repeat(1_048_500) })).toThrow('after rendering')
224
+ expect(() => prepareAgent({ instructions: 'a'.repeat(1_040_000) })).not.toThrow()
225
+ })
226
+ })
227
+
228
+ describe('bounded structured profile', () => {
229
+ test('checks nested objects, bounded arrays, required properties and nullable enums', () => {
230
+ const responseSchema = schema({
231
+ rows: {
232
+ type: 'array',
233
+ minItems: 1,
234
+ maxItems: 2,
235
+ items: closedObject({
236
+ count: { type: ['null', 'integer'] },
237
+ label: { type: ['string', 'null'], enum: ['known', null] },
238
+ }),
239
+ },
240
+ })
241
+ const prepared = prepareAgent({ instructions: 'Answer.', responseSchema })
242
+ expect(finishAgent(prepared, exchange('{"rows":[{"count":2,"label":null}]}')).outcome).toBe(
243
+ 'done',
244
+ )
245
+ for (const value of [
246
+ { rows: [] },
247
+ { rows: [{ count: 1.2, label: null }] },
248
+ { rows: [{ count: null, label: 'unknown' }] },
249
+ { rows: [{ count: 1 }] },
250
+ ]) {
251
+ expect(() => finishAgent(prepared, exchange(JSON.stringify(value)))).toThrow()
252
+ }
253
+ })
254
+
255
+ test('rejects every unsupported shape instead of widening the profile', () => {
256
+ for (const node of [
257
+ { type: 'boolean' },
258
+ { type: 'number' },
259
+ { type: 'array', items: { type: 'string' } },
260
+ { type: 'array', items: { type: 'string' }, maxItems: 257 },
261
+ { type: 'string', minLength: 1 },
262
+ { type: ['string', 'null'], enum: ['known'] },
263
+ { type: ['string', 'null'], enum: [null] },
264
+ { type: 'string', enum: ['duplicate', 'duplicate'] },
265
+ ]) {
266
+ expect(() => assertResponseSchema(schema({ value: node }))).toThrow()
267
+ }
268
+ expect(() => assertResponseSchema({ ...answerSchema, required: [] })).toThrow()
269
+ expect(() => assertResponseSchema({ ...answerSchema, additionalProperties: true })).toThrow()
270
+ expect(() =>
271
+ assertResponseSchema({
272
+ ...answerSchema,
273
+ $schema: 'https://json-schema.org/draft/2020-12/schema',
274
+ }),
275
+ ).toThrow()
276
+ })
277
+
278
+ test('enforces depth, object property, aggregate property, enum and schema byte limits', () => {
279
+ let deep: JsonObject = { type: 'integer' }
280
+ for (let depth = 0; depth < 8; depth += 1) deep = closedObject({ value: deep })
281
+ expect(() => assertResponseSchema({ $schema: answerSchema.$schema!, ...deep })).toThrow()
282
+ const many = Object.fromEntries(
283
+ Array.from({ length: 33 }, (_, i) => [`field${i}`, { type: 'integer' }]),
284
+ )
285
+ expect(() => assertResponseSchema(schema(many))).toThrow()
286
+ const groups = Object.fromEntries(
287
+ Array.from({ length: 32 }, (_, i) => [
288
+ `group${i}`,
289
+ closedObject({
290
+ a: { type: 'integer' },
291
+ b: { type: 'integer' },
292
+ c: { type: 'integer' },
293
+ d: { type: 'integer' },
294
+ }),
295
+ ]),
296
+ )
297
+ expect(() => assertResponseSchema(schema(groups))).toThrow()
298
+ const enums = Array.from({ length: 129 }, (_, i) => String(i))
299
+ expect(() =>
300
+ assertResponseSchema(
301
+ schema({ a: { type: 'string', enum: enums }, b: { type: 'string', enum: enums } }),
302
+ ),
303
+ ).toThrow()
304
+ expect(() =>
305
+ assertResponseSchema({ ...answerSchema, description: 'x'.repeat(262_144) }),
306
+ ).toThrow('256 KiB')
307
+ })
308
+ })
@@ -0,0 +1,81 @@
1
+ import { expect, test } from 'bun:test'
2
+ import { execFileSync } from 'node:child_process'
3
+ import { mkdir, mkdtemp, readdir, readFile, rm } from 'node:fs/promises'
4
+ import { tmpdir } from 'node:os'
5
+ import { join } from 'node:path'
6
+ import { fileURLToPath } from 'node:url'
7
+
8
+ test('ordinary packing retains editable source, registry dependencies and a standalone runtime', async () => {
9
+ const root = fileURLToPath(new URL('../', import.meta.url))
10
+ const temporary = await mkdtemp(join(tmpdir(), 'agent-method-pack-test-'))
11
+ const originalManifest = await readFile(join(root, 'package.json'), 'utf8')
12
+ try {
13
+ const first = join(temporary, 'first')
14
+ const extracted = join(temporary, 'extracted')
15
+ const repacked = join(temporary, 'repacked')
16
+ await mkdir(extracted)
17
+ await mkdir(first)
18
+ await mkdir(repacked)
19
+ const pack = (cwd: string, destination: string) =>
20
+ execFileSync(
21
+ process.execPath,
22
+ ['pm', 'pack', '--ignore-scripts', '--destination', destination],
23
+ {
24
+ cwd,
25
+ stdio: 'pipe',
26
+ },
27
+ )
28
+ pack(root, first)
29
+ const archives = (await readdir(first)).filter((name) => name.endsWith('.tgz'))
30
+ expect(archives).toHaveLength(1)
31
+ const archive = join(first, archives[0]!)
32
+ const entries = execFileSync('tar', ['-tzf', archive], { encoding: 'utf8' }).trim().split('\n')
33
+ expect(entries).toContain('package/src/flow.ts')
34
+ expect(entries).toContain('package/dist/conversation.d.ts')
35
+ expect(entries).toContain('package/FLOW.contract.json')
36
+ expect(entries.some((name) => name.includes('/tooling/') || name.endsWith('.tgz'))).toBe(false)
37
+ expect(entries.some((name) => name.includes('/node_modules/'))).toBe(false)
38
+ execFileSync('tar', ['-xzf', archive, '--strip-components=1', '-C', extracted])
39
+ const manifest = JSON.parse(await readFile(join(extracted, 'package.json'), 'utf8'))
40
+ for (const name of ['@jigging/flow']) {
41
+ const version = manifest.devDependencies[name]
42
+ expect(version).toMatch(/^\d+\.\d+\.\d+/)
43
+ expect(version).not.toMatch(/workspace:|file:/)
44
+ }
45
+ expect(Object.keys(manifest.dependencies ?? {})).toHaveLength(0)
46
+ // Public built exports and the complete Flow bundle work without development dependencies.
47
+ execFileSync(
48
+ process.execPath,
49
+ [
50
+ '-e',
51
+ `await import('./dist/flow.js');
52
+ const { prepareAgent, checkAgentResult } = await import('@jigging/agent-method');
53
+ const reference = '013579ab-cdef-4567-89ab-0123456789ab';
54
+ const prepared = prepareAgent({ instructions: 'Answer.', session: { restore: reference } });
55
+ if (prepared.session.restore !== reference || prepared.request.prompt.includes(reference)) throw new Error('invalid session preparation');
56
+ const result = checkAgentResult({ outcome: 'done', output: { text: 'answer', session: { status: 'retained', reference } } });
57
+ if (result.output.session.reference !== reference || !Object.isFrozen(result.output.session)) throw new Error('invalid session receipt');
58
+ const conversation = await import('@jigging/agent-method/conversation');
59
+ if (typeof conversation.withAgentConversation !== 'function') throw new Error('missing public conversation helper')`,
60
+ ],
61
+ { cwd: extracted, stdio: 'pipe' },
62
+ )
63
+ pack(extracted, repacked)
64
+ const next = (await readdir(repacked)).filter((name) => name.endsWith('.tgz'))
65
+ expect(next).toHaveLength(1)
66
+ const nextManifest = JSON.parse(
67
+ execFileSync('tar', ['-xOf', join(repacked, next[0]!), 'package/package.json'], {
68
+ encoding: 'utf8',
69
+ }),
70
+ )
71
+ expect(nextManifest.devDependencies).toEqual(manifest.devDependencies)
72
+ expect(
73
+ execFileSync('tar', ['-xOf', join(repacked, next[0]!), 'package/src/flow.ts'], {
74
+ encoding: 'utf8',
75
+ }),
76
+ ).toBe(await readFile(join(extracted, 'src/flow.ts'), 'utf8'))
77
+ expect(await readFile(join(root, 'package.json'), 'utf8')).toBe(originalManifest)
78
+ } finally {
79
+ await rm(temporary, { recursive: true, force: true })
80
+ }
81
+ }, 30_000)
@@ -0,0 +1,103 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { checkAgentResult } from '../src/index.js'
3
+
4
+ const schema = {
5
+ $schema: 'https://flow.jig.md/schemas/schema-0.json',
6
+ type: 'object',
7
+ properties: { answer: { type: 'string' } },
8
+ required: ['answer'],
9
+ additionalProperties: false,
10
+ }
11
+
12
+ describe('consumer-side Agent result checking', () => {
13
+ test('validates immutable retained and unavailable session receipts', () => {
14
+ const reference = '013579ab-cdef-4567-89ab-0123456789ab'
15
+ for (const session of [
16
+ { status: 'retained', reference },
17
+ ...['not-cleanly-closed', 'missing-history', 'unsupported-history', 'capacity'].map(
18
+ (reason) => ({ status: 'unavailable', reason }),
19
+ ),
20
+ ]) {
21
+ const result = checkAgentResult({ outcome: 'done', output: { text: 'answer', session } })
22
+ expect(result.output.session).toEqual(session)
23
+ expect(result.output.session).not.toBe(session)
24
+ expect(Object.isFrozen(result.output.session)).toBe(true)
25
+ }
26
+ for (const session of [
27
+ null,
28
+ {},
29
+ { status: 'retained' },
30
+ { status: 'retained', reference: 'private/path' },
31
+ { status: 'retained', reference: reference.toUpperCase() },
32
+ { status: 'retained', reference: `${reference}\n` },
33
+ { status: 'unavailable', reference },
34
+ { status: 'unavailable' },
35
+ { status: 'unavailable', reason: 'unknown' },
36
+ { status: 'retained', reference, extra: true },
37
+ ])
38
+ expect(() => checkAgentResult({ outcome: 'done', output: { text: '', session } })).toThrow()
39
+ })
40
+
41
+ test('takes an immutable snapshot independent of the selected implementation', () => {
42
+ const input = { outcome: 'done', output: { text: 'answer', structured: { answer: 'yes' } } }
43
+ const result = checkAgentResult(input, schema)
44
+ input.output.structured.answer = 'changed'
45
+ expect(result.output.structured).toEqual({ answer: 'yes' })
46
+ expect(Object.isFrozen(result.output.structured)).toBe(true)
47
+ expect(Object.isFrozen(result.output)).toBe(true)
48
+ expect(Object.isFrozen(result)).toBe(true)
49
+ })
50
+
51
+ test('requires structured completion, but permits an honest blocked or limited answer', () => {
52
+ expect(() =>
53
+ checkAgentResult({ outcome: 'done', output: { text: 'no data' } }, schema),
54
+ ).toThrow('requires a structured result')
55
+ for (const outcome of ['blocked', 'limit']) {
56
+ expect(
57
+ checkAgentResult({ outcome, output: { text: 'Could not finish.' } }, schema).outcome,
58
+ ).toBe(outcome)
59
+ expect(() =>
60
+ checkAgentResult(
61
+ { outcome, output: { text: 'Invalid data', structured: { answer: 3 } } },
62
+ schema,
63
+ ),
64
+ ).toThrow('does not match responseSchema')
65
+ }
66
+ })
67
+
68
+ test('rejects malformed or embellished envelopes and mismatched dynamic data', () => {
69
+ for (const result of [
70
+ null,
71
+ [],
72
+ {},
73
+ { outcome: 'invented', output: { text: '' } },
74
+ { outcome: 'done', output: { text: 3 } },
75
+ { outcome: 'done', output: { text: '', extra: true } },
76
+ { outcome: 'done', output: { text: '' }, trusted: true },
77
+ ])
78
+ expect(() => checkAgentResult(result)).toThrow()
79
+ expect(() =>
80
+ checkAgentResult(
81
+ { outcome: 'done', output: { text: '', structured: { answer: 3 } } },
82
+ schema,
83
+ ),
84
+ ).toThrow('does not match responseSchema')
85
+ expect(() =>
86
+ checkAgentResult({ outcome: 'done', output: { text: '', structured: 9007199254740992 } }),
87
+ ).toThrow()
88
+ expect(() => checkAgentResult({ outcome: 'done', output: { text: '\ud800' } })).toThrow()
89
+ })
90
+
91
+ test('never invokes a supplied result getter', () => {
92
+ let reads = 0
93
+ const value = {
94
+ outcome: 'done',
95
+ get output() {
96
+ reads++
97
+ return { text: 'hidden' }
98
+ },
99
+ }
100
+ expect(() => checkAgentResult(value)).toThrow()
101
+ expect(reads).toBe(0)
102
+ })
103
+ })