tjs-lang 0.8.1 → 0.8.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.
Files changed (78) hide show
  1. package/CLAUDE.md +13 -4
  2. package/demo/autocomplete.test.ts +37 -0
  3. package/demo/docs.json +698 -8
  4. package/demo/examples.test.ts +6 -2
  5. package/demo/src/autocomplete.ts +40 -2
  6. package/demo/src/introspection-bridge.ts +140 -0
  7. package/demo/src/introspection-doc.test.ts +63 -0
  8. package/demo/src/playground-shared.ts +101 -16
  9. package/demo/src/playground-test-results.test.ts +112 -0
  10. package/demo/src/tjs-playground.ts +32 -5
  11. package/dist/examples/modules/dist/main.d.ts +34 -0
  12. package/dist/examples/modules/dist/math.d.ts +120 -0
  13. package/dist/index.js +115 -112
  14. package/dist/index.js.map +4 -4
  15. package/dist/src/lang/core.d.ts +1 -1
  16. package/dist/src/lang/dialect.d.ts +35 -0
  17. package/dist/src/lang/emitters/ast.d.ts +1 -1
  18. package/dist/src/lang/emitters/js-tests.d.ts +7 -0
  19. package/dist/src/lang/emitters/js.d.ts +12 -0
  20. package/dist/src/lang/index.d.ts +2 -1
  21. package/dist/src/lang/parser-types.d.ts +17 -0
  22. package/dist/src/lang/parser.d.ts +18 -0
  23. package/dist/src/lang/transpiler.d.ts +1 -0
  24. package/dist/src/lang/types.d.ts +18 -0
  25. package/dist/src/vm/runtime.d.ts +18 -0
  26. package/dist/src/vm/vm.d.ts +15 -1
  27. package/dist/tjs-batteries.js +2 -2
  28. package/dist/tjs-batteries.js.map +2 -2
  29. package/dist/tjs-eval.js +43 -43
  30. package/dist/tjs-eval.js.map +3 -3
  31. package/dist/tjs-from-ts.js +1 -1
  32. package/dist/tjs-from-ts.js.map +2 -2
  33. package/dist/tjs-lang.js +76 -73
  34. package/dist/tjs-lang.js.map +4 -4
  35. package/dist/tjs-vm.js +49 -49
  36. package/dist/tjs-vm.js.map +3 -3
  37. package/editors/codemirror/ajs-language.ts +130 -44
  38. package/editors/codemirror/completion-source.test.ts +114 -0
  39. package/editors/introspect-value.test.ts +61 -0
  40. package/editors/introspect-value.ts +86 -0
  41. package/editors/scope-symbols.test.ts +113 -0
  42. package/editors/scope-symbols.ts +173 -0
  43. package/llms.txt +1 -0
  44. package/package.json +21 -1
  45. package/src/batteries/audit.ts +3 -2
  46. package/src/cli/commands/check.ts +3 -2
  47. package/src/cli/commands/emit.ts +4 -2
  48. package/src/cli/commands/run.ts +6 -2
  49. package/src/cli/commands/types.ts +2 -2
  50. package/src/lang/codegen.test.ts +4 -1
  51. package/src/lang/core.ts +6 -4
  52. package/src/lang/dialect.test.ts +63 -0
  53. package/src/lang/dialect.ts +50 -0
  54. package/src/lang/emitters/ast.ts +145 -2
  55. package/src/lang/emitters/js-tests.ts +46 -37
  56. package/src/lang/emitters/js.ts +19 -2
  57. package/src/lang/features.test.ts +6 -5
  58. package/src/lang/index.ts +40 -5
  59. package/src/lang/parser-types.ts +17 -0
  60. package/src/lang/parser.test.ts +12 -6
  61. package/src/lang/parser.ts +113 -3
  62. package/src/lang/predicate-schema.test.ts +97 -0
  63. package/src/lang/predicate-schema.ts +168 -0
  64. package/src/lang/predicate.test.ts +184 -0
  65. package/src/lang/predicate.ts +550 -0
  66. package/src/lang/subset-invariant.test.ts +90 -0
  67. package/src/lang/suggest.test.ts +84 -0
  68. package/src/lang/transpiler.ts +34 -0
  69. package/src/lang/types.ts +18 -0
  70. package/src/lang/wasm.test.ts +8 -2
  71. package/src/linalg/linalg.test.ts +8 -2
  72. package/src/use-cases/batteries.test.ts +4 -0
  73. package/src/use-cases/local-helpers.test.ts +219 -0
  74. package/src/use-cases/timeout-overrides.test.ts +169 -0
  75. package/src/vm/atom-effects.test.ts +72 -0
  76. package/src/vm/atoms/batteries.ts +29 -3
  77. package/src/vm/runtime.ts +140 -4
  78. package/src/vm/vm.ts +47 -9
@@ -0,0 +1,84 @@
1
+ import { describe, it, expect } from 'bun:test'
2
+ import { suggest } from './predicate'
3
+
4
+ const vals = (s: ReturnType<typeof suggest>) =>
5
+ s.filter((x) => x.kind === 'value').map((x) => x.value)
6
+ const stubs = (s: ReturnType<typeof suggest>) =>
7
+ s.filter((x) => x.kind === 'stub').map((x) => x.value)
8
+
9
+ describe('suggest — autocomplete from predicate clusters', () => {
10
+ // The keyword set is a plain array literal + an equality check; the open-ended
11
+ // values ride in `startsWith` guards. TS could enumerate the union but not the
12
+ // open-ended `var(--`; a TS `string` fallback offers neither.
13
+ const ANIMATION = String.raw`
14
+ var TIMING = ['linear','ease','ease-in','ease-out','ease-in-out']
15
+ function isTime(t){ return /^-?[0-9.]+m?s$/.test(t) }
16
+ function isIter(t){ return t == 'infinite' || /^[0-9.]+$/.test(t) }
17
+ function isVar(t){ return typeof t == 'string' && t.startsWith('var(--') }
18
+ function isTok(t){ return isTime(t) || isIter(t) || TIMING.includes(t) || isVar(t) }
19
+ function isAnimationToken(v){ return typeof v == 'string' && isTok(v.trim()) }
20
+ `
21
+
22
+ it('mines the keyword set (array literal + equality literal)', () => {
23
+ const v = vals(suggest(ANIMATION))
24
+ expect(v).toContain('linear')
25
+ expect(v).toContain('ease-in-out')
26
+ expect(v).toContain('infinite') // from `t == 'infinite'`
27
+ })
28
+
29
+ it('mines open-ended `startsWith` guards as stubs (TS string can offer none)', () => {
30
+ expect(stubs(suggest(ANIMATION))).toContain('var(--')
31
+ })
32
+
33
+ it('filters by the prefix typed so far', () => {
34
+ const v = vals(suggest(ANIMATION, { prefix: 'ease-' }))
35
+ expect(v).toEqual(['ease-in', 'ease-in-out', 'ease-out'])
36
+ expect(v).not.toContain('linear')
37
+ })
38
+
39
+ it('a stub matches when the user is mid-typing into it', () => {
40
+ // user has typed `var(`; the `var(--` stub is still the relevant completion
41
+ expect(stubs(suggest(ANIMATION, { prefix: 'var(' }))).toContain('var(--')
42
+ // and once they pass it, it still matches by startsWith
43
+ expect(stubs(suggest(ANIMATION, { prefix: 'var(--' }))).toContain('var(--')
44
+ })
45
+
46
+ it('validated suggestions are exactly what the predicate accepts', () => {
47
+ // `bad` is in the keyword array but the entry predicate excludes it — so the
48
+ // mined candidate is filtered out by running it through the predicate.
49
+ const src = String.raw`
50
+ var ALL = ['yes','maybe','bad']
51
+ function check(v){ return ALL.includes(v) && v != 'bad' }
52
+ `
53
+ const v = vals(suggest(src))
54
+ expect(v).toContain('yes')
55
+ expect(v).toContain('maybe')
56
+ expect(v).not.toContain('bad') // mined, but predicate rejects it
57
+ })
58
+
59
+ it('validate:false returns raw mined candidates (no predicate run)', () => {
60
+ const src = String.raw`
61
+ var ALL = ['yes','maybe','bad']
62
+ function check(v){ return ALL.includes(v) && v != 'bad' }
63
+ `
64
+ const v = vals(suggest(src, { validate: false }))
65
+ expect(v).toContain('yes')
66
+ expect(v).toContain('bad') // un-validated → the excluded literal leaks in
67
+ })
68
+
69
+ it('respects limit', () => {
70
+ expect(suggest(ANIMATION, { limit: 2 }).length).toBe(2)
71
+ })
72
+
73
+ it('values sort before stubs', () => {
74
+ const s = suggest(ANIMATION)
75
+ const firstStub = s.findIndex((x) => x.kind === 'stub')
76
+ const lastValue = s.map((x) => x.kind).lastIndexOf('value')
77
+ expect(lastValue).toBeLessThan(firstStub)
78
+ })
79
+
80
+ it('empty / unparseable source yields nothing', () => {
81
+ expect(suggest('')).toEqual([])
82
+ expect(suggest('function (')).toEqual([])
83
+ })
84
+ })
@@ -16,6 +16,40 @@ export { transpile, ajs, tjs, createAgent, getToolDefinitions } from './core'
16
16
  // Parser
17
17
  export { parse, preprocess, extractTDoc } from './parser'
18
18
 
19
+ // Dialect resolution for file-based tooling (extension → dialect)
20
+ export {
21
+ dialectForFilename,
22
+ sourceKindForFilename,
23
+ type Dialect,
24
+ type SourceKind,
25
+ } from './dialect'
26
+
27
+ // Predicate-safety: verify a cluster of pure, synchronous, composable predicates
28
+ export {
29
+ verifyPredicate,
30
+ compilePredicate,
31
+ suggest,
32
+ effectfulFromAtoms,
33
+ formatPredicateDiagnostics,
34
+ PredicateFuelExhausted,
35
+ type PredicateDiagnostic,
36
+ type PredicateVerifyResult,
37
+ type VerifyPredicateOptions,
38
+ type CompilePredicateOptions,
39
+ type Suggestion,
40
+ type SuggestOptions,
41
+ } from './predicate'
42
+
43
+ // Predicate-aware JSON-Schema: the `$predicate` keyword (computational types)
44
+ export {
45
+ compilePredicateSchema,
46
+ validatePredicateSchema,
47
+ type PredicateSchema,
48
+ type SchemaError,
49
+ type SchemaValidationResult,
50
+ type PredicateSchemaOptions,
51
+ } from './predicate-schema'
52
+
19
53
  // AST emitter
20
54
  export { transformFunction } from './emitters/ast'
21
55
 
package/src/lang/types.ts CHANGED
@@ -225,6 +225,21 @@ export interface TransformContext {
225
225
  filename: string
226
226
  /** Options */
227
227
  options: TranspileOptions
228
+ /**
229
+ * Helper functions (top-level functions declared before the entry function).
230
+ * Calls to these names emit `callLocal` instead of treating them as atom calls.
231
+ */
232
+ helpers?: Map<string, any /* FunctionDeclaration */>
233
+ /**
234
+ * Cache of transformed helper bodies. Lazily populated as helpers are
235
+ * referenced from the entry function (or from other helpers).
236
+ */
237
+ helperSteps?: Map<string, { steps: any[]; paramNames: string[] }>
238
+ /**
239
+ * Names of helpers currently being transformed. Used to detect direct or
240
+ * transitive recursion.
241
+ */
242
+ helperTransforming?: Set<string>
228
243
  }
229
244
 
230
245
  /** Create a child context for nested scopes */
@@ -239,6 +254,9 @@ export function createChildContext(parent: TransformContext): TransformContext {
239
254
  source: parent.source,
240
255
  filename: parent.filename,
241
256
  options: parent.options,
257
+ helpers: parent.helpers,
258
+ helperSteps: parent.helperSteps,
259
+ helperTransforming: parent.helperTransforming,
242
260
  }
243
261
  }
244
262
 
@@ -2317,14 +2317,20 @@ describe('boundary distribution form (Phase 4)', () => {
2317
2317
  async function dynamicImportLibrary(transpiled: string): Promise<any> {
2318
2318
  const { tmpdir } = await import('node:os')
2319
2319
  const { join } = await import('node:path')
2320
- const { writeFileSync, unlinkSync } = await import('node:fs')
2320
+ const { writeFileSync, unlinkSync, realpathSync } = await import('node:fs')
2321
+ const { pathToFileURL } = await import('node:url')
2321
2322
  const path = join(
2322
2323
  tmpdir(),
2323
2324
  `tjs-lib-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.mjs`
2324
2325
  )
2325
2326
  writeFileSync(path, transpiled)
2327
+ // Resolve macOS /var → /private/var symlink and use file:// URL — bun's
2328
+ // resolver in subsequent test cases (after a `new Function(emittedCode)`
2329
+ // execution earlier in the same file) fails on bare absolute paths with
2330
+ // "Cannot find module ... from ''". This form is robust to that state.
2331
+ const url = pathToFileURL(realpathSync(path)).href
2326
2332
  try {
2327
- const mod = await import(path)
2333
+ const mod = await import(url)
2328
2334
  // Wait for the async wasm bootstrap inside the module to finish.
2329
2335
  // The bootstrap runs as a top-level IIFE; instantiation is async.
2330
2336
  await new Promise((r) => setTimeout(r, 100))
@@ -14,9 +14,10 @@
14
14
  */
15
15
 
16
16
  import { describe, it, expect } from 'bun:test'
17
- import { readFileSync } from 'node:fs'
17
+ import { readFileSync, realpathSync } from 'node:fs'
18
18
  import { join } from 'node:path'
19
19
  import { tmpdir } from 'node:os'
20
+ import { pathToFileURL } from 'node:url'
20
21
  import { writeFileSync, unlinkSync } from 'node:fs'
21
22
 
22
23
  const LINALG_PATH = join(import.meta.dir, 'index.tjs')
@@ -41,8 +42,13 @@ async function dynamicImportLibrary(transpiled: string): Promise<any> {
41
42
  `linalg-test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.mjs`
42
43
  )
43
44
  writeFileSync(path, transpiled)
45
+ // Resolve macOS /var → /private/var symlink and use file:// URL — bun's
46
+ // resolver in subsequent test cases (after a `new Function(emittedCode)`
47
+ // execution earlier in the same file) fails on bare absolute paths with
48
+ // "Cannot find module ... from ''". This form is robust to that state.
49
+ const url = pathToFileURL(realpathSync(path)).href
44
50
  try {
45
- const mod = await import(path)
51
+ const mod = await import(url)
46
52
  // Wait for the async wasm bootstrap inside the module to finish
47
53
  await new Promise((r) => setTimeout(r, 100))
48
54
  return mod
@@ -252,4 +252,8 @@ describe('Batteries Included', () => {
252
252
  expect(msg.tool_calls[0].function.name).toBe('get_weather')
253
253
  expect(msg.tool_calls[0].function.arguments).toBe('{"city":"Paris"}')
254
254
  })
255
+
256
+ it('LLM battery atoms have IO-friendly timeouts', () => {
257
+ expect(llmPredictBattery.timeoutMs).toBeGreaterThanOrEqual(60000)
258
+ })
255
259
  })
@@ -0,0 +1,219 @@
1
+ import { describe, it, expect } from 'bun:test'
2
+ import { ajs } from '../transpiler'
3
+ import { transpile } from '../lang'
4
+ import { AgentVM } from '../vm'
5
+
6
+ /**
7
+ * Local helper functions: an agent source file may declare multiple top-level
8
+ * functions. The LAST is the entry point; the preceding ones are helpers,
9
+ * invoked via the `callLocal` atom. Helpers are top-level siblings — isolated
10
+ * scopes that see only their parameters, like ordinary functions.
11
+ */
12
+ describe('Local helper functions', () => {
13
+ const VM = new AgentVM()
14
+ const run = async (src: string, args: Record<string, any>) =>
15
+ (await VM.run(ajs(src), args)).result
16
+
17
+ it('invokes a scalar-returning helper from the entry function', async () => {
18
+ const result = await run(
19
+ `
20
+ function double(x) { return x * 2 }
21
+ function main(n) {
22
+ const d = double(n)
23
+ return { d }
24
+ }`,
25
+ { n: 5 }
26
+ )
27
+ expect(result.d).toBe(10)
28
+ })
29
+
30
+ it('lets a helper call another helper', async () => {
31
+ const result = await run(
32
+ `
33
+ function double(x) { return x * 2 }
34
+ function addOne(x) {
35
+ const d = double(x)
36
+ return d + 1
37
+ }
38
+ function main(n) {
39
+ const a = double(n)
40
+ const b = addOne(n)
41
+ return { a, b }
42
+ }`,
43
+ { n: 5 }
44
+ )
45
+ expect(result).toEqual({ a: 10, b: 11 })
46
+ })
47
+
48
+ it('allows helpers to return arrays and objects', async () => {
49
+ const result = await run(
50
+ `
51
+ function pair(x) { return [x, x] }
52
+ function wrap(x) { return { value: x } }
53
+ function main(n) {
54
+ const p = pair(n)
55
+ const w = wrap(n)
56
+ return { p, w }
57
+ }`,
58
+ { n: 3 }
59
+ )
60
+ expect(result).toEqual({ p: [3, 3], w: { value: 3 } })
61
+ })
62
+
63
+ it('supports idiomatic TJS typed/example params (colon shorthand)', async () => {
64
+ // Examples are representative values: floats for a fractional-scaling fn.
65
+ const result = await run(
66
+ `
67
+ function scale(x: 1.5, factor: 0.5): 0.75 { return x * factor }
68
+ function main(n: 1.5): 0.75 {
69
+ const s = scale(n, 0.5)
70
+ return { s }
71
+ }`,
72
+ { n: 3 }
73
+ )
74
+ expect(result.s).toBe(1.5)
75
+ })
76
+
77
+ it('supports multiple parameters', async () => {
78
+ const result = await run(
79
+ `
80
+ function add(a, b) { return a + b }
81
+ function main(x, y) {
82
+ const sum = add(x, y)
83
+ return { sum }
84
+ }`,
85
+ { x: 7, y: 8 }
86
+ )
87
+ expect(result.sum).toBe(15)
88
+ })
89
+
90
+ it('isolates helper scope — helpers cannot see the caller locals', async () => {
91
+ // `secret` is a local in main (= 42). The helper references a bare
92
+ // `secret`; under isolation it is NOT in scope, so AJS falls back to the
93
+ // bare-string literal "secret". A scope leak would instead expose main's
94
+ // 42 — so the guarantee is simply: the helper never sees the caller value.
95
+ const result = await run(
96
+ `
97
+ function leak(x) { return { x, secret } }
98
+ function main(n) {
99
+ const secret = 42
100
+ const r = leak(n)
101
+ return r
102
+ }`,
103
+ { n: 1 }
104
+ )
105
+ expect(result.x).toBe(1)
106
+ expect(result.secret).not.toBe(42) // no leak of caller's local
107
+ expect(result.secret).toBe('secret') // AJS bare-string fallback
108
+ })
109
+
110
+ it('does not let helper return exit the caller agent', async () => {
111
+ // The helper returns, then main keeps running and returns its own object.
112
+ const result = await run(
113
+ `
114
+ function step(x) { return x + 100 }
115
+ function main(n) {
116
+ const a = step(n)
117
+ const b = step(a)
118
+ return { a, b }
119
+ }`,
120
+ { n: 1 }
121
+ )
122
+ expect(result).toEqual({ a: 101, b: 201 })
123
+ })
124
+
125
+ // --- transpile-time guards -------------------------------------------------
126
+
127
+ const expectTranspileError = (src: string, match: RegExp) =>
128
+ expect(() => transpile(src, { vmTarget: true })).toThrow(match)
129
+
130
+ it('rejects helper calls nested inside expressions', () => {
131
+ expectTranspileError(
132
+ `
133
+ function double(x) { return x * 2 }
134
+ function main(n) { return { v: double(n) + 1 } }`,
135
+ /cannot be called inside an expression/
136
+ )
137
+ })
138
+
139
+ it('supports recursion (bounded by fuel/timeout, not rejected)', async () => {
140
+ // Recursive factorial — by-reference dispatch makes this a runtime loop.
141
+ const result = await run(
142
+ `
143
+ function fact(n) {
144
+ if (n <= 1) { return 1 }
145
+ const prev = fact(n - 1)
146
+ return n * prev
147
+ }
148
+ function main(n) {
149
+ const f = fact(n)
150
+ return { f }
151
+ }`,
152
+ { n: 5 }
153
+ )
154
+ expect(result.f).toBe(120)
155
+ })
156
+
157
+ it('supports mutual recursion', async () => {
158
+ const result = await run(
159
+ `
160
+ function isEven(n) {
161
+ if (n == 0) { return true }
162
+ const r = isOdd(n - 1)
163
+ return r
164
+ }
165
+ function isOdd(n) {
166
+ if (n == 0) { return false }
167
+ const r = isEven(n - 1)
168
+ return r
169
+ }
170
+ function main(n) {
171
+ const even = isEven(n)
172
+ return { even }
173
+ }`,
174
+ { n: 10 }
175
+ )
176
+ expect(result.even).toBe(true)
177
+ })
178
+
179
+ it('turns runaway recursion into a clean error, not a host crash', async () => {
180
+ // Unbounded recursion: must surface as a monadic error (depth cap or fuel),
181
+ // never a thrown RangeError that crashes the host VM.
182
+ const out = await VM.run(
183
+ ajs(`
184
+ function loop(n) { const r = loop(n); return r }
185
+ function main(n) { const v = loop(n); return { v } }`),
186
+ { n: 1 },
187
+ { fuel: 100000 }
188
+ )
189
+ expect(out.error).toBeDefined()
190
+ expect(out.error?.message).toMatch(/depth|fuel/i)
191
+ })
192
+
193
+ it('rejects arity mismatches', () => {
194
+ expectTranspileError(
195
+ `
196
+ function add(a, b) { return a + b }
197
+ function main(n) { const v = add(n); return { v } }`,
198
+ /expects 2 argument\(s\), got 1/
199
+ )
200
+ })
201
+
202
+ it('rejects same-name same-arity functions (collision)', () => {
203
+ // Two identical-signature `dup` declarations collide. The polymorphic-merge
204
+ // pass (which runs during parse, before helper extraction) catches this
205
+ // first — either way it's a clear, deterministic rejection.
206
+ expectTranspileError(
207
+ `
208
+ function dup(x) { return x }
209
+ function dup(x) { return x }
210
+ function main(n) { const v = dup(n); return { v } }`,
211
+ /ambiguous signatures|Duplicate helper/
212
+ )
213
+ })
214
+
215
+ it('still transpiles a single-function agent (no helpers)', async () => {
216
+ const result = await run(`function main(n) { return { n } }`, { n: 9 })
217
+ expect(result.n).toBe(9)
218
+ })
219
+ })
@@ -0,0 +1,169 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { AgentVM, isAgentError, defineAtom } from '../index'
3
+ import { s } from 'tosijs-schema'
4
+
5
+ // A deliberately slow atom whose builtin timeout would normally trip.
6
+ const slowAtom = defineAtom(
7
+ 'slow',
8
+ s.object({ ms: s.number }),
9
+ undefined,
10
+ async ({ ms }) => {
11
+ await new Promise((resolve) => setTimeout(resolve, ms))
12
+ },
13
+ { docs: 'Slow IO atom for testing', cost: 0.01, timeoutMs: 100 }
14
+ )
15
+
16
+ describe('Per-atom Timeout Overrides', () => {
17
+ it('uses the atom default when no override is provided', async () => {
18
+ const vm = new AgentVM({ slow: slowAtom })
19
+
20
+ const ast = vm.Agent.step({ op: 'slow', ms: 200 }).toJSON()
21
+
22
+ const result = await vm.run(ast, {}, { fuel: 100 })
23
+
24
+ expect(result.error).toBeDefined()
25
+ expect(result.error?.message).toContain("Atom 'slow' timed out")
26
+ })
27
+
28
+ it('static override raises an atom timeout above its default', async () => {
29
+ const vm = new AgentVM({ slow: slowAtom })
30
+
31
+ const ast = vm.Agent.step({ op: 'slow', ms: 200 }).toJSON()
32
+
33
+ const result = await vm.run(
34
+ ast,
35
+ {},
36
+ {
37
+ fuel: 100,
38
+ timeoutOverrides: { slow: 5000 }, // raise from 100 → 5s
39
+ }
40
+ )
41
+
42
+ expect(result.error).toBeUndefined()
43
+ })
44
+
45
+ it('static override lowers an atom timeout below its default', async () => {
46
+ const vm = new AgentVM({ slow: slowAtom })
47
+
48
+ const ast = vm.Agent.step({ op: 'slow', ms: 100 }).toJSON()
49
+
50
+ const result = await vm.run(
51
+ ast,
52
+ {},
53
+ {
54
+ fuel: 100,
55
+ timeoutOverrides: { slow: 10 }, // lower from 100ms → 10ms
56
+ }
57
+ )
58
+
59
+ expect(result.error).toBeDefined()
60
+ expect(result.error?.message).toContain("Atom 'slow' timed out")
61
+ })
62
+
63
+ it('dynamic override receives input and ctx', async () => {
64
+ const vm = new AgentVM({ slow: slowAtom })
65
+
66
+ const ast = vm.Agent.step({ op: 'slow', ms: 200 }).toJSON()
67
+
68
+ const result = await vm.run(
69
+ ast,
70
+ {},
71
+ {
72
+ fuel: 100,
73
+ timeoutOverrides: {
74
+ // Allow 10x the requested delay
75
+ slow: (input: any) => input.ms * 10,
76
+ },
77
+ }
78
+ )
79
+
80
+ expect(result.error).toBeUndefined()
81
+ })
82
+
83
+ it('override of 0 disables the per-atom timeout', async () => {
84
+ const vm = new AgentVM({ slow: slowAtom })
85
+
86
+ // ms > atom default (100), would normally trip
87
+ const ast = vm.Agent.step({ op: 'slow', ms: 300 }).toJSON()
88
+
89
+ const result = await vm.run(
90
+ ast,
91
+ {},
92
+ {
93
+ fuel: 100,
94
+ timeoutOverrides: { slow: 0 },
95
+ }
96
+ )
97
+
98
+ expect(result.error).toBeUndefined()
99
+ })
100
+
101
+ it('atom timeout still fires when no override matches that op', async () => {
102
+ const vm = new AgentVM({ slow: slowAtom })
103
+
104
+ const ast = vm.Agent.step({ op: 'slow', ms: 200 }).toJSON()
105
+
106
+ const result = await vm.run(
107
+ ast,
108
+ {},
109
+ {
110
+ fuel: 100,
111
+ timeoutOverrides: { somethingElse: 5000 },
112
+ }
113
+ )
114
+
115
+ expect(result.error).toBeDefined()
116
+ expect(isAgentError(result.error)).toBe(true)
117
+ })
118
+ })
119
+
120
+ describe('Default vm.run timeout', () => {
121
+ it('does not derive run timeout from fuel (formula removed)', async () => {
122
+ // Under the old `fuel * 10ms` formula, fuel=1 would timeout in 10ms —
123
+ // far less than the IO needed below. Under the atom-derived default
124
+ // (slowest atom × 2), a 200ms IO atom completes fine with tiny fuel.
125
+ const vm = new AgentVM({ slow: slowAtom })
126
+
127
+ const ast = vm.Agent.step({ op: 'slow', ms: 200 }).toJSON()
128
+
129
+ const result = await vm.run(
130
+ ast,
131
+ {},
132
+ {
133
+ fuel: 1000, // headroom for cost; the run-level timeout is what matters
134
+ timeoutOverrides: { slow: 0 }, // disable per-atom timeout
135
+ // no timeoutMs option — use the default
136
+ }
137
+ )
138
+
139
+ expect(result.error).toBeUndefined()
140
+ })
141
+
142
+ it('derives the default from the slowest atom × 2 (never below the slowest atom budget)', () => {
143
+ // coreAtoms include 120s atoms (llm/runCode); the run-level default must
144
+ // cover them, else those per-atom budgets are dead config.
145
+ const core = new AgentVM()
146
+ expect(core.defaultRunTimeout).toBe(240_000) // 120s × 2
147
+
148
+ // A slower custom atom raises the default (self-adjusting).
149
+ const slow = defineAtom('slow5m', s.object({}), undefined, async () => {}, {
150
+ timeoutMs: 300_000,
151
+ })
152
+ const vm = new AgentVM({ slow5m: slow })
153
+ expect(vm.defaultRunTimeout).toBe(600_000) // 300s × 2
154
+ // and it always covers the slowest atom's own budget
155
+ expect(vm.defaultRunTimeout).toBeGreaterThanOrEqual(300_000)
156
+ })
157
+
158
+ it('floors the default at 60s for a VM whose atoms are all fast', () => {
159
+ // `seq` etc. have timeoutMs: 0 (no timeout) and are excluded; if the only
160
+ // finite budgets were tiny, the floor keeps a sane backstop.
161
+ const fast = defineAtom('fast', s.object({}), undefined, async () => {}, {
162
+ timeoutMs: 50,
163
+ })
164
+ const vm = new AgentVM({ fast })
165
+ // coreAtoms still contribute their 120s atoms, so this VM is 240s; the floor
166
+ // is exercised conceptually — assert it's at least the 60s minimum.
167
+ expect(vm.defaultRunTimeout).toBeGreaterThanOrEqual(60_000)
168
+ })
169
+ })
@@ -0,0 +1,72 @@
1
+ import { describe, it, expect } from 'bun:test'
2
+ import { coreAtoms, EFFECTFUL_CORE_OPS, type AtomDef } from './runtime'
3
+ import { batteryAtoms } from './atoms/index'
4
+
5
+ const allAtoms = { ...coreAtoms, ...batteryAtoms } as Record<string, AtomDef>
6
+
7
+ describe('atom effects classification (predicate-safety keystone)', () => {
8
+ it('every atom is classified pure | io', () => {
9
+ for (const [op, atom] of Object.entries(allAtoms)) {
10
+ expect(atom.effects, `${op} has no effects tag`).toMatch(/^(pure|io)$/)
11
+ }
12
+ })
13
+
14
+ it('the declared effectful core ops are all tagged io', () => {
15
+ for (const op of EFFECTFUL_CORE_OPS) {
16
+ expect(coreAtoms[op as keyof typeof coreAtoms]?.effects, op).toBe('io')
17
+ }
18
+ })
19
+
20
+ it('every battery atom is io (network calls)', () => {
21
+ for (const atom of Object.values(batteryAtoms) as AtomDef[]) {
22
+ expect(atom.effects, atom.op).toBe('io')
23
+ }
24
+ })
25
+
26
+ it('the pure substrate a predicate relies on is tagged pure', () => {
27
+ // data / control-flow atoms predicates compose from
28
+ const pure = [
29
+ 'map',
30
+ 'filter',
31
+ 'reduce',
32
+ 'find',
33
+ 'len',
34
+ 'split',
35
+ 'join',
36
+ 'keys',
37
+ 'pick',
38
+ 'omit',
39
+ 'merge',
40
+ 'regexMatch',
41
+ 'jsonParse',
42
+ 'callLocal',
43
+ 'if',
44
+ 'while',
45
+ 'return',
46
+ 'scope',
47
+ ]
48
+ for (const op of pure) {
49
+ expect(coreAtoms[op as keyof typeof coreAtoms]?.effects, op).toBe('pure')
50
+ }
51
+ })
52
+
53
+ it('the IO atoms are exactly the ones that may not appear in a predicate', () => {
54
+ const io = Object.values(allAtoms)
55
+ .filter((a) => a.effects === 'io')
56
+ .map((a) => a.op)
57
+ // sanity: the capability-touching atoms are all present
58
+ expect(io).toEqual(
59
+ expect.arrayContaining([
60
+ 'httpFetch',
61
+ 'storeGet',
62
+ 'llmPredict',
63
+ 'agentRun',
64
+ 'runCode',
65
+ 'llmVision',
66
+ ])
67
+ )
68
+ // and none of the pure data ops leaked in
69
+ expect(io).not.toContain('map')
70
+ expect(io).not.toContain('jsonParse')
71
+ })
72
+ })