@vibe-lang/runtime 0.2.5

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 (250) hide show
  1. package/package.json +46 -0
  2. package/src/ast/index.ts +375 -0
  3. package/src/ast.ts +2 -0
  4. package/src/debug/advanced-features.ts +482 -0
  5. package/src/debug/bun-inspector.ts +424 -0
  6. package/src/debug/handoff-manager.ts +283 -0
  7. package/src/debug/index.ts +150 -0
  8. package/src/debug/runner.ts +365 -0
  9. package/src/debug/server.ts +565 -0
  10. package/src/debug/stack-merger.ts +267 -0
  11. package/src/debug/state.ts +581 -0
  12. package/src/debug/test/advanced-features.test.ts +300 -0
  13. package/src/debug/test/e2e.test.ts +218 -0
  14. package/src/debug/test/handoff-manager.test.ts +256 -0
  15. package/src/debug/test/runner.test.ts +256 -0
  16. package/src/debug/test/stack-merger.test.ts +163 -0
  17. package/src/debug/test/state.test.ts +400 -0
  18. package/src/debug/test/ts-debug-integration.test.ts +374 -0
  19. package/src/debug/test/ts-import-tracker.test.ts +125 -0
  20. package/src/debug/test/ts-source-map.test.ts +169 -0
  21. package/src/debug/ts-import-tracker.ts +151 -0
  22. package/src/debug/ts-source-map.ts +171 -0
  23. package/src/errors/index.ts +124 -0
  24. package/src/index.ts +358 -0
  25. package/src/lexer/index.ts +348 -0
  26. package/src/lexer.ts +2 -0
  27. package/src/parser/index.ts +792 -0
  28. package/src/parser/parse.ts +45 -0
  29. package/src/parser/test/async.test.ts +248 -0
  30. package/src/parser/test/destructuring.test.ts +167 -0
  31. package/src/parser/test/do-expression.test.ts +486 -0
  32. package/src/parser/test/errors/do-expression.test.ts +95 -0
  33. package/src/parser/test/errors/error-locations.test.ts +230 -0
  34. package/src/parser/test/errors/invalid-expressions.test.ts +144 -0
  35. package/src/parser/test/errors/missing-tokens.test.ts +126 -0
  36. package/src/parser/test/errors/model-declaration.test.ts +185 -0
  37. package/src/parser/test/errors/nested-blocks.test.ts +226 -0
  38. package/src/parser/test/errors/unclosed-delimiters.test.ts +122 -0
  39. package/src/parser/test/errors/unexpected-tokens.test.ts +120 -0
  40. package/src/parser/test/import-export.test.ts +143 -0
  41. package/src/parser/test/literals.test.ts +404 -0
  42. package/src/parser/test/model-declaration.test.ts +161 -0
  43. package/src/parser/test/nested-blocks.test.ts +402 -0
  44. package/src/parser/test/parser.test.ts +743 -0
  45. package/src/parser/test/private.test.ts +136 -0
  46. package/src/parser/test/template-literal.test.ts +127 -0
  47. package/src/parser/test/tool-declaration.test.ts +302 -0
  48. package/src/parser/test/ts-block.test.ts +252 -0
  49. package/src/parser/test/type-annotations.test.ts +254 -0
  50. package/src/parser/visitor/helpers.ts +330 -0
  51. package/src/parser/visitor.ts +794 -0
  52. package/src/parser.ts +2 -0
  53. package/src/runtime/ai/cache-chunking.test.ts +69 -0
  54. package/src/runtime/ai/cache-chunking.ts +73 -0
  55. package/src/runtime/ai/client.ts +109 -0
  56. package/src/runtime/ai/context.ts +168 -0
  57. package/src/runtime/ai/formatters.ts +316 -0
  58. package/src/runtime/ai/index.ts +38 -0
  59. package/src/runtime/ai/language-ref.ts +38 -0
  60. package/src/runtime/ai/providers/anthropic.ts +253 -0
  61. package/src/runtime/ai/providers/google.ts +201 -0
  62. package/src/runtime/ai/providers/openai.ts +156 -0
  63. package/src/runtime/ai/retry.ts +100 -0
  64. package/src/runtime/ai/return-tools.ts +301 -0
  65. package/src/runtime/ai/test/client.test.ts +83 -0
  66. package/src/runtime/ai/test/formatters.test.ts +485 -0
  67. package/src/runtime/ai/test/retry.test.ts +137 -0
  68. package/src/runtime/ai/test/return-tools.test.ts +450 -0
  69. package/src/runtime/ai/test/tool-loop.test.ts +319 -0
  70. package/src/runtime/ai/test/tool-schema.test.ts +241 -0
  71. package/src/runtime/ai/tool-loop.ts +203 -0
  72. package/src/runtime/ai/tool-schema.ts +151 -0
  73. package/src/runtime/ai/types.ts +113 -0
  74. package/src/runtime/ai-logger.ts +255 -0
  75. package/src/runtime/ai-provider.ts +347 -0
  76. package/src/runtime/async/dependencies.ts +276 -0
  77. package/src/runtime/async/executor.ts +293 -0
  78. package/src/runtime/async/index.ts +43 -0
  79. package/src/runtime/async/scheduling.ts +163 -0
  80. package/src/runtime/async/test/dependencies.test.ts +284 -0
  81. package/src/runtime/async/test/executor.test.ts +388 -0
  82. package/src/runtime/context.ts +357 -0
  83. package/src/runtime/exec/ai.ts +139 -0
  84. package/src/runtime/exec/expressions.ts +475 -0
  85. package/src/runtime/exec/frames.ts +26 -0
  86. package/src/runtime/exec/functions.ts +305 -0
  87. package/src/runtime/exec/interpolation.ts +312 -0
  88. package/src/runtime/exec/statements.ts +604 -0
  89. package/src/runtime/exec/tools.ts +129 -0
  90. package/src/runtime/exec/typescript.ts +215 -0
  91. package/src/runtime/exec/variables.ts +279 -0
  92. package/src/runtime/index.ts +975 -0
  93. package/src/runtime/modules.ts +452 -0
  94. package/src/runtime/serialize.ts +103 -0
  95. package/src/runtime/state.ts +489 -0
  96. package/src/runtime/stdlib/core.ts +45 -0
  97. package/src/runtime/stdlib/directory.test.ts +156 -0
  98. package/src/runtime/stdlib/edit.test.ts +154 -0
  99. package/src/runtime/stdlib/fastEdit.test.ts +201 -0
  100. package/src/runtime/stdlib/glob.test.ts +106 -0
  101. package/src/runtime/stdlib/grep.test.ts +144 -0
  102. package/src/runtime/stdlib/index.ts +16 -0
  103. package/src/runtime/stdlib/readFile.test.ts +123 -0
  104. package/src/runtime/stdlib/tools/index.ts +707 -0
  105. package/src/runtime/stdlib/writeFile.test.ts +157 -0
  106. package/src/runtime/step.ts +969 -0
  107. package/src/runtime/test/ai-context.test.ts +1086 -0
  108. package/src/runtime/test/ai-result-object.test.ts +419 -0
  109. package/src/runtime/test/ai-tool-flow.test.ts +859 -0
  110. package/src/runtime/test/async-execution-order.test.ts +618 -0
  111. package/src/runtime/test/async-execution.test.ts +344 -0
  112. package/src/runtime/test/async-nested.test.ts +660 -0
  113. package/src/runtime/test/async-parallel-timing.test.ts +546 -0
  114. package/src/runtime/test/basic1.test.ts +154 -0
  115. package/src/runtime/test/binary-operators.test.ts +431 -0
  116. package/src/runtime/test/break-statement.test.ts +257 -0
  117. package/src/runtime/test/context-modes.test.ts +650 -0
  118. package/src/runtime/test/context.test.ts +466 -0
  119. package/src/runtime/test/core-functions.test.ts +228 -0
  120. package/src/runtime/test/e2e.test.ts +88 -0
  121. package/src/runtime/test/error-locations/error-locations.test.ts +80 -0
  122. package/src/runtime/test/error-locations/main-error.vibe +4 -0
  123. package/src/runtime/test/error-locations/main-import-error.vibe +3 -0
  124. package/src/runtime/test/error-locations/utils/helper.vibe +5 -0
  125. package/src/runtime/test/for-in.test.ts +312 -0
  126. package/src/runtime/test/helpers.ts +69 -0
  127. package/src/runtime/test/imports.test.ts +334 -0
  128. package/src/runtime/test/json-expressions.test.ts +232 -0
  129. package/src/runtime/test/literals.test.ts +372 -0
  130. package/src/runtime/test/logical-indexing.test.ts +478 -0
  131. package/src/runtime/test/member-methods.test.ts +324 -0
  132. package/src/runtime/test/model-config.test.ts +338 -0
  133. package/src/runtime/test/null-handling.test.ts +342 -0
  134. package/src/runtime/test/private-visibility.test.ts +332 -0
  135. package/src/runtime/test/runtime-state.test.ts +514 -0
  136. package/src/runtime/test/scoping.test.ts +370 -0
  137. package/src/runtime/test/string-interpolation.test.ts +354 -0
  138. package/src/runtime/test/template-literal.test.ts +181 -0
  139. package/src/runtime/test/tool-execution.test.ts +467 -0
  140. package/src/runtime/test/tool-schema-generation.test.ts +477 -0
  141. package/src/runtime/test/tostring.test.ts +210 -0
  142. package/src/runtime/test/ts-block.test.ts +594 -0
  143. package/src/runtime/test/ts-error-location.test.ts +231 -0
  144. package/src/runtime/test/types.test.ts +732 -0
  145. package/src/runtime/test/verbose-logger.test.ts +710 -0
  146. package/src/runtime/test/vibe-expression.test.ts +54 -0
  147. package/src/runtime/test/vibe-value-errors.test.ts +541 -0
  148. package/src/runtime/test/while.test.ts +232 -0
  149. package/src/runtime/tools/builtin.ts +30 -0
  150. package/src/runtime/tools/directory-tools.ts +70 -0
  151. package/src/runtime/tools/file-tools.ts +228 -0
  152. package/src/runtime/tools/index.ts +5 -0
  153. package/src/runtime/tools/registry.ts +48 -0
  154. package/src/runtime/tools/search-tools.ts +134 -0
  155. package/src/runtime/tools/security.ts +36 -0
  156. package/src/runtime/tools/system-tools.ts +312 -0
  157. package/src/runtime/tools/test/fixtures/base-types.ts +40 -0
  158. package/src/runtime/tools/test/fixtures/test-types.ts +132 -0
  159. package/src/runtime/tools/test/registry.test.ts +713 -0
  160. package/src/runtime/tools/test/security.test.ts +86 -0
  161. package/src/runtime/tools/test/system-tools.test.ts +679 -0
  162. package/src/runtime/tools/test/ts-schema.test.ts +357 -0
  163. package/src/runtime/tools/ts-schema.ts +341 -0
  164. package/src/runtime/tools/types.ts +89 -0
  165. package/src/runtime/tools/utility-tools.ts +198 -0
  166. package/src/runtime/ts-eval.ts +126 -0
  167. package/src/runtime/types.ts +797 -0
  168. package/src/runtime/validation.ts +160 -0
  169. package/src/runtime/verbose-logger.ts +459 -0
  170. package/src/runtime.ts +2 -0
  171. package/src/semantic/analyzer-context.ts +62 -0
  172. package/src/semantic/analyzer-validators.ts +575 -0
  173. package/src/semantic/analyzer-visitors.ts +534 -0
  174. package/src/semantic/analyzer.ts +83 -0
  175. package/src/semantic/index.ts +11 -0
  176. package/src/semantic/symbol-table.ts +58 -0
  177. package/src/semantic/test/async-validation.test.ts +301 -0
  178. package/src/semantic/test/compress-validation.test.ts +179 -0
  179. package/src/semantic/test/const-reassignment.test.ts +111 -0
  180. package/src/semantic/test/control-flow.test.ts +346 -0
  181. package/src/semantic/test/destructuring.test.ts +185 -0
  182. package/src/semantic/test/duplicate-declarations.test.ts +168 -0
  183. package/src/semantic/test/export-validation.test.ts +111 -0
  184. package/src/semantic/test/fixtures/math.ts +31 -0
  185. package/src/semantic/test/imports.test.ts +148 -0
  186. package/src/semantic/test/json-type.test.ts +68 -0
  187. package/src/semantic/test/literals.test.ts +127 -0
  188. package/src/semantic/test/model-validation.test.ts +179 -0
  189. package/src/semantic/test/prompt-validation.test.ts +343 -0
  190. package/src/semantic/test/scoping.test.ts +312 -0
  191. package/src/semantic/test/tool-validation.test.ts +306 -0
  192. package/src/semantic/test/ts-type-checking.test.ts +563 -0
  193. package/src/semantic/test/type-constraints.test.ts +111 -0
  194. package/src/semantic/test/type-inference.test.ts +87 -0
  195. package/src/semantic/test/type-validation.test.ts +552 -0
  196. package/src/semantic/test/undefined-variables.test.ts +163 -0
  197. package/src/semantic/ts-block-checker.ts +204 -0
  198. package/src/semantic/ts-signatures.ts +194 -0
  199. package/src/semantic/ts-types.ts +170 -0
  200. package/src/semantic/types.ts +58 -0
  201. package/tests/fixtures/conditional-logic.vibe +14 -0
  202. package/tests/fixtures/function-call.vibe +16 -0
  203. package/tests/fixtures/imports/cycle-detection/a.vibe +6 -0
  204. package/tests/fixtures/imports/cycle-detection/b.vibe +5 -0
  205. package/tests/fixtures/imports/cycle-detection/main.vibe +3 -0
  206. package/tests/fixtures/imports/module-isolation/main-b.vibe +8 -0
  207. package/tests/fixtures/imports/module-isolation/main.vibe +9 -0
  208. package/tests/fixtures/imports/module-isolation/moduleA.vibe +6 -0
  209. package/tests/fixtures/imports/module-isolation/moduleB.vibe +6 -0
  210. package/tests/fixtures/imports/nested-import/helper.vibe +6 -0
  211. package/tests/fixtures/imports/nested-import/main.vibe +3 -0
  212. package/tests/fixtures/imports/nested-import/utils.ts +3 -0
  213. package/tests/fixtures/imports/nested-isolation/file2.vibe +15 -0
  214. package/tests/fixtures/imports/nested-isolation/file3.vibe +10 -0
  215. package/tests/fixtures/imports/nested-isolation/main.vibe +21 -0
  216. package/tests/fixtures/imports/pure-cycle/a.vibe +5 -0
  217. package/tests/fixtures/imports/pure-cycle/b.vibe +5 -0
  218. package/tests/fixtures/imports/pure-cycle/main.vibe +3 -0
  219. package/tests/fixtures/imports/ts-boolean/checks.ts +14 -0
  220. package/tests/fixtures/imports/ts-boolean/main.vibe +10 -0
  221. package/tests/fixtures/imports/ts-boolean/type-mismatch.vibe +5 -0
  222. package/tests/fixtures/imports/ts-boolean/use-constant.vibe +18 -0
  223. package/tests/fixtures/imports/ts-error-handling/helpers.ts +42 -0
  224. package/tests/fixtures/imports/ts-error-handling/main.vibe +5 -0
  225. package/tests/fixtures/imports/ts-import/main.vibe +4 -0
  226. package/tests/fixtures/imports/ts-import/math.ts +9 -0
  227. package/tests/fixtures/imports/ts-variables/call-non-function.vibe +5 -0
  228. package/tests/fixtures/imports/ts-variables/data.ts +10 -0
  229. package/tests/fixtures/imports/ts-variables/import-json.vibe +5 -0
  230. package/tests/fixtures/imports/ts-variables/import-type-mismatch.vibe +5 -0
  231. package/tests/fixtures/imports/ts-variables/import-variable.vibe +5 -0
  232. package/tests/fixtures/imports/vibe-import/greet.vibe +5 -0
  233. package/tests/fixtures/imports/vibe-import/main.vibe +3 -0
  234. package/tests/fixtures/multiple-ai-calls.vibe +10 -0
  235. package/tests/fixtures/simple-greeting.vibe +6 -0
  236. package/tests/fixtures/template-literals.vibe +11 -0
  237. package/tests/integration/basic-ai/basic-ai.integration.test.ts +166 -0
  238. package/tests/integration/basic-ai/basic-ai.vibe +12 -0
  239. package/tests/integration/bug-fix/bug-fix.integration.test.ts +201 -0
  240. package/tests/integration/bug-fix/buggy-code.ts +22 -0
  241. package/tests/integration/bug-fix/fix-bug.vibe +21 -0
  242. package/tests/integration/compress/compress.integration.test.ts +206 -0
  243. package/tests/integration/destructuring/destructuring.integration.test.ts +92 -0
  244. package/tests/integration/hello-world-translator/hello-world-translator.integration.test.ts +61 -0
  245. package/tests/integration/line-annotator/context-modes.integration.test.ts +261 -0
  246. package/tests/integration/line-annotator/line-annotator.integration.test.ts +148 -0
  247. package/tests/integration/multi-feature/cumulative-sum.integration.test.ts +75 -0
  248. package/tests/integration/multi-feature/number-analyzer.integration.test.ts +191 -0
  249. package/tests/integration/multi-feature/number-analyzer.vibe +59 -0
  250. package/tests/integration/tool-calls/tool-calls.integration.test.ts +93 -0
@@ -0,0 +1,1086 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { parse } from '../../parser/parse';
3
+ import {
4
+ createInitialState,
5
+ runUntilPause,
6
+ resumeWithAIResponse,
7
+ buildLocalContext,
8
+ type ContextVariable,
9
+ } from '../index';
10
+ import { formatContextForAI } from '../context';
11
+ import { runWithMockAI } from './helpers';
12
+
13
+ describe('AI Context Tests', () => {
14
+ // ============================================================================
15
+ // Prompt type filtering in AI calls
16
+ // ============================================================================
17
+
18
+ test('prompt typed variables filtered from context during do call', () => {
19
+ const ast = parse(`
20
+ const SYSTEM: prompt = "You are a helpful assistant"
21
+ let userQuestion: prompt = "What is 2+2?"
22
+ let userData = "some user data"
23
+ model m = { name: "test", apiKey: "key", url: "http://test" }
24
+ let result = vibe userQuestion m default
25
+ `);
26
+
27
+ let state = createInitialState(ast);
28
+ state = runUntilPause(state);
29
+
30
+ expect(state.status).toBe('awaiting_ai');
31
+ expect(state.pendingAI?.prompt).toBe('What is 2+2?');
32
+
33
+ // Prompt typed variables (SYSTEM, userQuestion) and model should be filtered out
34
+ // Only userData should remain
35
+ expect(state.localContext).toEqual([
36
+ { kind: 'variable', name: 'userData', value: 'some user data', type: 'text', isConst: false, frameName: '<entry>', frameDepth: 0, source: null },
37
+ ]);
38
+
39
+ expect(state.globalContext).toEqual(state.localContext);
40
+ });
41
+
42
+ test('prompt typed variables filtered in nested function context', () => {
43
+ const ast = parse(`
44
+ const GLOBAL_PROMPT: prompt = "Global system prompt"
45
+ const GLOBAL_DATA = "global data value"
46
+ model m = { name: "test", apiKey: "key", url: "http://test" }
47
+
48
+ function processQuery(input: text): text {
49
+ const LOCAL_PROMPT: prompt = "Process this: {input}"
50
+ let localData = "local data"
51
+ return vibe LOCAL_PROMPT m default
52
+ }
53
+
54
+ let result = processQuery("user query")
55
+ `);
56
+
57
+ let state = createInitialState(ast);
58
+ state = runUntilPause(state);
59
+
60
+ expect(state.status).toBe('awaiting_ai');
61
+ // With unified interpolation, {input} is left as reference in prompt-typed variables
62
+ expect(state.pendingAI?.prompt).toBe('Process this: {input}');
63
+
64
+ // Local context: function frame only, LOCAL_PROMPT filtered out
65
+ // Note: function parameters now have explicit type annotations
66
+ expect(state.localContext).toEqual([
67
+ { kind: 'variable', name: 'input', value: 'user query', type: 'text', isConst: false, frameName: 'processQuery', frameDepth: 1, source: null },
68
+ { kind: 'variable', name: 'localData', value: 'local data', type: 'text', isConst: false, frameName: 'processQuery', frameDepth: 1, source: null },
69
+ ]);
70
+
71
+ // Global context: entry frame + function frame, all prompts and model filtered
72
+ expect(state.globalContext).toEqual([
73
+ { kind: 'variable', name: 'GLOBAL_DATA', value: 'global data value', type: 'text', isConst: true, frameName: '<entry>', frameDepth: 0, source: null },
74
+ { kind: 'variable', name: 'input', value: 'user query', type: 'text', isConst: false, frameName: 'processQuery', frameDepth: 1, source: null },
75
+ { kind: 'variable', name: 'localData', value: 'local data', type: 'text', isConst: false, frameName: 'processQuery', frameDepth: 1, source: null },
76
+ ]);
77
+ });
78
+
79
+ test('prompt filtering with multiple do calls and result assignment', () => {
80
+ // Assignment order: inputData, model, ANALYZE_PROMPT, analyzed, SUMMARIZE_PROMPT, summary
81
+ // Context should show visible variables in assignment order (prompts/model filtered)
82
+ const ast = parse(`
83
+ let inputData = "raw data"
84
+ model m = { name: "test", apiKey: "key", url: "http://test" }
85
+ const ANALYZE_PROMPT: prompt = "Analyze this"
86
+ let analyzed = vibe ANALYZE_PROMPT m default
87
+ const SUMMARIZE_PROMPT: prompt = "Summarize this"
88
+ let summary = vibe SUMMARIZE_PROMPT m default
89
+ `);
90
+
91
+ let state = createInitialState(ast);
92
+
93
+ // First do call - after inputData, model, ANALYZE_PROMPT assigned
94
+ state = runUntilPause(state);
95
+ expect(state.status).toBe('awaiting_ai');
96
+ expect(state.pendingAI?.prompt).toBe('Analyze this');
97
+
98
+ // Context at first AI call: only inputData visible (model, ANALYZE_PROMPT filtered)
99
+ expect(state.localContext).toEqual([
100
+ { kind: 'variable', name: 'inputData', value: 'raw data', type: 'text', isConst: false, frameName: '<entry>', frameDepth: 0, source: null },
101
+ ]);
102
+
103
+ // Verify formatted text context at first pause
104
+ const formatted1 = formatContextForAI(state.localContext, { includeInstructions: false });
105
+ expect(formatted1.text).toBe(
106
+ ` <entry> (current scope)
107
+ - inputData (text): raw data`
108
+ );
109
+
110
+ // Resume - analyzed gets assigned, then SUMMARIZE_PROMPT, then pause at second do
111
+ state = resumeWithAIResponse(state, 'analysis result');
112
+ state = runUntilPause(state);
113
+ expect(state.status).toBe('awaiting_ai');
114
+ expect(state.pendingAI?.prompt).toBe('Summarize this');
115
+
116
+ // Context at second AI call: inputData, do prompt with response, analyzed (in execution order)
117
+ expect(state.localContext).toEqual([
118
+ { kind: 'variable', name: 'inputData', value: 'raw data', type: 'text', isConst: false, source: null, frameName: '<entry>', frameDepth: 0 },
119
+ { kind: 'prompt', aiType: 'vibe', prompt: 'Analyze this', response: 'analysis result', frameName: '<entry>', frameDepth: 0 },
120
+ { kind: 'variable', name: 'analyzed', value: 'analysis result', type: 'text', isConst: false, source: 'ai', frameName: '<entry>', frameDepth: 0 },
121
+ ]);
122
+
123
+ // Verify formatted text context at second pause - shows execution order with prompt and response
124
+ // Response is shown via variable assignment (not duplicated with prompt)
125
+ const formatted2 = formatContextForAI(state.localContext, { includeInstructions: false });
126
+ expect(formatted2.text).toBe(
127
+ ` <entry> (current scope)
128
+ - inputData (text): raw data
129
+ --> vibe: "Analyze this"
130
+ <-- analyzed (text): analysis result`
131
+ );
132
+
133
+ // Complete execution
134
+ state = resumeWithAIResponse(state, 'summary result');
135
+ state = runUntilPause(state);
136
+ expect(state.status).toBe('completed');
137
+
138
+ // Verify locals have values in assignment order (including filtered types)
139
+ const locals = state.callStack[0].locals;
140
+ expect(locals['inputData'].value).toBe('raw data');
141
+ // model filtered from context
142
+ expect(locals['ANALYZE_PROMPT'].value).toBe('Analyze this');
143
+ // VibeValue: value is directly in .value (not .value.value)
144
+ expect(locals['analyzed'].value).toBe('analysis result');
145
+ expect(locals['SUMMARIZE_PROMPT'].value).toBe('Summarize this');
146
+ expect(locals['summary'].value).toBe('summary result');
147
+
148
+ // Context at completion - includes all variables and prompts (with responses) in execution order
149
+ expect(state.localContext).toEqual([
150
+ { kind: 'variable', name: 'inputData', value: 'raw data', type: 'text', isConst: false, source: null, frameName: '<entry>', frameDepth: 0 },
151
+ { kind: 'prompt', aiType: 'vibe', prompt: 'Analyze this', response: 'analysis result', frameName: '<entry>', frameDepth: 0 },
152
+ { kind: 'variable', name: 'analyzed', value: 'analysis result', type: 'text', isConst: false, source: 'ai', frameName: '<entry>', frameDepth: 0 },
153
+ { kind: 'prompt', aiType: 'vibe', prompt: 'Summarize this', response: 'summary result', frameName: '<entry>', frameDepth: 0 },
154
+ { kind: 'variable', name: 'summary', value: 'summary result', type: 'text', isConst: false, source: 'ai', frameName: '<entry>', frameDepth: 0 },
155
+ ]);
156
+
157
+ // Verify formatted text context at completion - shows all entries in execution order
158
+ // Responses shown via variable assignments (not duplicated with prompts)
159
+ const formatted3 = formatContextForAI(state.localContext, { includeInstructions: false });
160
+ expect(formatted3.text).toBe(
161
+ ` <entry> (current scope)
162
+ - inputData (text): raw data
163
+ --> vibe: "Analyze this"
164
+ <-- analyzed (text): analysis result
165
+ --> vibe: "Summarize this"
166
+ <-- summary (text): summary result`
167
+ );
168
+ });
169
+
170
+ test('deeply nested functions with prompt variables at each level', () => {
171
+ const ast = parse(`
172
+ const ROOT_PROMPT: prompt = "Root prompt"
173
+ const ROOT_DATA = "root data"
174
+ model m = { name: "test", apiKey: "key", url: "http://test" }
175
+
176
+ function level1(input: text): text {
177
+ const L1_PROMPT: prompt = "Level 1 prompt"
178
+ let l1Data = "level 1 data"
179
+ return level2(input)
180
+ }
181
+
182
+ function level2(input: text): text {
183
+ const L2_PROMPT: prompt = "Level 2 prompt"
184
+ let l2Data = "level 2 data"
185
+ return vibe "Process {input}" m default
186
+ }
187
+
188
+ let result = level1("deep input")
189
+ `);
190
+
191
+ let state = createInitialState(ast);
192
+ state = runUntilPause(state);
193
+
194
+ expect(state.status).toBe('awaiting_ai');
195
+ // With unified interpolation, {input} is left as reference in vibe expressions
196
+ expect(state.pendingAI?.prompt).toBe('Process {input}');
197
+
198
+ // Local context: level2 frame only, L2_PROMPT filtered
199
+ // Note: function parameters now have explicit type annotations
200
+ expect(state.localContext).toEqual([
201
+ { kind: 'variable', name: 'input', value: 'deep input', type: 'text', isConst: false, frameName: 'level2', frameDepth: 2, source: null },
202
+ { kind: 'variable', name: 'l2Data', value: 'level 2 data', type: 'text', isConst: false, frameName: 'level2', frameDepth: 2, source: null },
203
+ ]);
204
+
205
+ // Global context: all frames, all prompts filtered, all models filtered
206
+ expect(state.globalContext).toEqual([
207
+ { kind: 'variable', name: 'ROOT_DATA', value: 'root data', type: 'text', isConst: true, frameName: '<entry>', frameDepth: 0, source: null },
208
+ { kind: 'variable', name: 'input', value: 'deep input', type: 'text', isConst: false, frameName: 'level1', frameDepth: 1, source: null },
209
+ { kind: 'variable', name: 'l1Data', value: 'level 1 data', type: 'text', isConst: false, frameName: 'level1', frameDepth: 1, source: null },
210
+ { kind: 'variable', name: 'input', value: 'deep input', type: 'text', isConst: false, frameName: 'level2', frameDepth: 2, source: null },
211
+ { kind: 'variable', name: 'l2Data', value: 'level 2 data', type: 'text', isConst: false, frameName: 'level2', frameDepth: 2, source: null },
212
+ ]);
213
+
214
+ // Verify formatted output shows proper nesting without any prompts
215
+ const formatted = formatContextForAI(state.globalContext, { includeInstructions: false });
216
+ expect(formatted.text).toBe(
217
+ ` <entry> (entry)
218
+ - ROOT_DATA (text): root data
219
+
220
+ level1 (depth 1)
221
+ - input (text): deep input
222
+ - l1Data (text): level 1 data
223
+
224
+ level2 (current scope)
225
+ - input (text): deep input
226
+ - l2Data (text): level 2 data`
227
+ );
228
+ });
229
+
230
+ // ============================================================================
231
+ // Context captured before AI calls
232
+ // ============================================================================
233
+
234
+ test('context captured before do call', () => {
235
+ const ast = parse(`
236
+ const API_KEY = "secret"
237
+ let counter = "0"
238
+ model m = { name: "test", apiKey: "key123", url: "http://test" }
239
+ let result = vibe "process data" m default
240
+ `);
241
+
242
+ let state = createInitialState(ast);
243
+ state = runUntilPause(state);
244
+
245
+ expect(state.status).toBe('awaiting_ai');
246
+
247
+ // Verify complete local context before AI call (models filtered out)
248
+ expect(state.localContext).toEqual([
249
+ { kind: 'variable', name: 'API_KEY', value: 'secret', type: 'text', isConst: true, frameName: '<entry>', frameDepth: 0, source: null },
250
+ { kind: 'variable', name: 'counter', value: '0', type: 'text', isConst: false, frameName: '<entry>', frameDepth: 0, source: null },
251
+ ]);
252
+
253
+ // Global context same as local at top level
254
+ expect(state.globalContext).toEqual(state.localContext);
255
+ });
256
+
257
+ test('context includes function parameters when inside function', () => {
258
+ const ast = parse(`
259
+ model m = { name: "test", apiKey: "key", url: "http://test" }
260
+ function process(input: text): text {
261
+ let localVar = "local value"
262
+ return vibe "process {input}" m default
263
+ }
264
+ let result = process("my input")
265
+ `);
266
+
267
+ let state = createInitialState(ast);
268
+ state = runUntilPause(state);
269
+
270
+ expect(state.status).toBe('awaiting_ai');
271
+
272
+ // Local context should have function params and locals only (depth 1 = called from entry)
273
+ // Note: function parameters now have explicit type annotations
274
+ expect(state.localContext).toEqual([
275
+ { kind: 'variable', name: 'input', value: 'my input', type: 'text', isConst: false, frameName: 'process', frameDepth: 1, source: null },
276
+ { kind: 'variable', name: 'localVar', value: 'local value', type: 'text', isConst: false, frameName: 'process', frameDepth: 1, source: null },
277
+ ]);
278
+
279
+ // Global context has entry frame (depth 0, model filtered out) + function frame (depth 1)
280
+ expect(state.globalContext).toEqual([
281
+ { kind: 'variable', name: 'input', value: 'my input', type: 'text', isConst: false, frameName: 'process', frameDepth: 1, source: null },
282
+ { kind: 'variable', name: 'localVar', value: 'local value', type: 'text', isConst: false, frameName: 'process', frameDepth: 1, source: null },
283
+ ]);
284
+ });
285
+
286
+ // ============================================================================
287
+ // Context formatter tests
288
+ // ============================================================================
289
+
290
+ test('context formatter formats variables in declaration order', () => {
291
+ // Note: models are filtered out before reaching formatter
292
+ const context: ContextVariable[] = [
293
+ { kind: 'variable', name: 'mutableVar', value: 'changing', type: null, isConst: false, frameName: '<entry>', frameDepth: 0 },
294
+ { kind: 'variable', name: 'CONFIG', value: { key: 'value' }, type: 'json', isConst: true, frameName: '<entry>', frameDepth: 0, source: null },
295
+ { kind: 'variable', name: 'anotherLet', value: 'also changing', type: null, isConst: false, frameName: '<entry>', frameDepth: 0 },
296
+ { kind: 'variable', name: 'SYSTEM_PROMPT', value: 'be helpful', type: 'text', isConst: true, frameName: '<entry>', frameDepth: 0, source: null },
297
+ ];
298
+
299
+ const formatted = formatContextForAI(context);
300
+
301
+ // Variables remain in original order (no sorting)
302
+ expect(formatted.variables).toEqual(context);
303
+
304
+ // Verify formatted text - all variables together in declaration order
305
+ expect(formatted.text).toBe(
306
+ `## VIBE Program Context
307
+ Variables from the VIBE language call stack.
308
+
309
+ <entry> (current scope)
310
+ - mutableVar: changing
311
+ - CONFIG (json): {"key":"value"}
312
+ - anotherLet: also changing
313
+ - SYSTEM_PROMPT (text): be helpful`
314
+ );
315
+ });
316
+
317
+ test('context formatter preserves declaration order', () => {
318
+ const context: ContextVariable[] = [
319
+ { kind: 'variable', name: 'z_const', value: 'z', type: null, isConst: true, frameName: '<entry>', frameDepth: 0, source: null },
320
+ { kind: 'variable', name: 'a_let', value: 'a', type: null, isConst: false, frameName: '<entry>', frameDepth: 0 },
321
+ { kind: 'variable', name: 'a_const', value: 'a', type: null, isConst: true, frameName: '<entry>', frameDepth: 0, source: null },
322
+ { kind: 'variable', name: 'z_let', value: 'z', type: null, isConst: false, frameName: '<entry>', frameDepth: 0 },
323
+ ];
324
+
325
+ const formatted = formatContextForAI(context);
326
+
327
+ // Variables remain in original declaration order
328
+ expect(formatted.variables).toEqual(context);
329
+
330
+ // Verify formatted text preserves order (no instructions for clarity)
331
+ const noInstructions = formatContextForAI(context, { includeInstructions: false });
332
+ expect(noInstructions.text).toBe(
333
+ ` <entry> (current scope)
334
+ - z_const: z
335
+ - a_let: a
336
+ - a_const: a
337
+ - z_let: z`
338
+ );
339
+ });
340
+
341
+ test('context with all type annotations formats correctly', () => {
342
+ // Note: models are filtered out before reaching formatter, so only text/json/null types
343
+ const context: ContextVariable[] = [
344
+ { kind: 'variable', name: 'jsonVar', value: { key: 'value' }, type: 'json', isConst: true, frameName: '<entry>', frameDepth: 0, source: null },
345
+ { kind: 'variable', name: 'textVar', value: 'text value', type: 'text', isConst: true, frameName: '<entry>', frameDepth: 0, source: null },
346
+ { kind: 'variable', name: 'untypedConst', value: 'constant', type: null, isConst: true, frameName: '<entry>', frameDepth: 0, source: null },
347
+ { kind: 'variable', name: 'untypedLet', value: 'mutable', type: null, isConst: false, frameName: '<entry>', frameDepth: 0 },
348
+ ];
349
+
350
+ const formatted = formatContextForAI(context);
351
+
352
+ // Verify formatted output - all variables together in declaration order
353
+ expect(formatted.text).toBe(
354
+ `## VIBE Program Context
355
+ Variables from the VIBE language call stack.
356
+
357
+ <entry> (current scope)
358
+ - jsonVar (json): {"key":"value"}
359
+ - textVar (text): text value
360
+ - untypedConst: constant
361
+ - untypedLet: mutable`
362
+ );
363
+
364
+ // Verify variables array matches input
365
+ expect(formatted.variables).toEqual(context);
366
+ });
367
+
368
+ test('context without instructions outputs variables only', () => {
369
+ const context: ContextVariable[] = [
370
+ { kind: 'variable', name: 'API_KEY', value: 'secret123', type: null, isConst: true, frameName: '<entry>', frameDepth: 0, source: null },
371
+ { kind: 'variable', name: 'counter', value: '42', type: null, isConst: false, frameName: '<entry>', frameDepth: 0 },
372
+ ];
373
+
374
+ const formatted = formatContextForAI(context, { includeInstructions: false });
375
+
376
+ expect(formatted.text).toBe(
377
+ ` <entry> (current scope)
378
+ - API_KEY: secret123
379
+ - counter: 42`
380
+ );
381
+ });
382
+
383
+ // ============================================================================
384
+ // Full program tests with mock AI
385
+ // ============================================================================
386
+
387
+ test('full program with mock AI response', () => {
388
+ const ast = parse(`
389
+ model m = { name: "test", apiKey: "key", url: "http://test" }
390
+ let input = "hello"
391
+ let result = vibe "transform {input}" m default
392
+ `);
393
+
394
+ let state = createInitialState(ast);
395
+ state = runWithMockAI(state, 'TRANSFORMED');
396
+
397
+ expect(state.status).toBe('completed');
398
+ // VibeValue: value is directly in .value
399
+ expect(state.callStack[0].locals['result'].value).toBe('TRANSFORMED');
400
+ });
401
+
402
+ test('multiple do calls with different mock responses', () => {
403
+ const ast = parse(`
404
+ model m = { name: "test", apiKey: "key", url: "http://test" }
405
+ let first = vibe "first prompt" m default
406
+ let second = vibe "second prompt" m default
407
+ `);
408
+
409
+ let state = createInitialState(ast);
410
+ state = runWithMockAI(state, {
411
+ 'first prompt': 'FIRST_RESPONSE',
412
+ 'second prompt': 'SECOND_RESPONSE',
413
+ });
414
+
415
+ expect(state.status).toBe('completed');
416
+ // VibeValue: value is directly in .value
417
+ expect(state.callStack[0].locals['first'].value).toBe('FIRST_RESPONSE');
418
+ expect(state.callStack[0].locals['second'].value).toBe('SECOND_RESPONSE');
419
+ });
420
+
421
+ test('context state correct after mock AI response', () => {
422
+ const ast = parse(`
423
+ model m = { name: "test", apiKey: "key", url: "http://test" }
424
+ const SYSTEM = "system prompt"
425
+ let result = vibe "query" m default
426
+ `);
427
+
428
+ let state = createInitialState(ast);
429
+ state = runWithMockAI(state, 'AI_RESPONSE');
430
+
431
+ expect(state.status).toBe('completed');
432
+
433
+ // Verify final variables have correct values and isConst
434
+ const locals = state.callStack[0].locals;
435
+ expect(locals['m'].isConst).toBe(true);
436
+ expect(locals['SYSTEM'].isConst).toBe(true);
437
+ expect(locals['result'].isConst).toBe(false);
438
+ // VibeValue: value is directly in .value
439
+ expect(locals['result'].value).toBe('AI_RESPONSE');
440
+ });
441
+
442
+ // ============================================================================
443
+ // Complex program context tests
444
+ // ============================================================================
445
+
446
+ test('complex program with mix of const, let, models - global scope', () => {
447
+ // Complex program with multiple models, constants, and variables
448
+ const ast = parse(`
449
+ const API_BASE = "https://api.example.com"
450
+ const CONFIG: json = { timeout: "30", retries: "3" }
451
+ model gpt = { name: "gpt-4", apiKey: "key1", url: "http://gpt" }
452
+ model claude = { name: "claude", apiKey: "key2", url: "http://claude" }
453
+ let userInput: text = "hello world"
454
+ let counter = "0"
455
+ let metadata: json = { version: "1.0" }
456
+ let result = vibe "process {userInput}" gpt default
457
+ `);
458
+
459
+ let state = createInitialState(ast);
460
+ state = runUntilPause(state);
461
+
462
+ expect(state.status).toBe('awaiting_ai');
463
+
464
+ // Models (gpt, claude) should be filtered out of context
465
+ // Verify complete local context with correct order and types (all in entry frame, depth 0)
466
+ expect(state.localContext).toEqual([
467
+ { kind: 'variable', name: 'API_BASE', value: 'https://api.example.com', type: 'text', isConst: true, source: null, frameName: '<entry>', frameDepth: 0 },
468
+ { kind: 'variable', name: 'CONFIG', value: { timeout: '30', retries: '3' }, type: 'json', isConst: true, source: null, frameName: '<entry>', frameDepth: 0 },
469
+ { kind: 'variable', name: 'userInput', value: 'hello world', type: 'text', isConst: false, source: null, frameName: '<entry>', frameDepth: 0 },
470
+ { kind: 'variable', name: 'counter', value: '0', type: 'text', isConst: false, source: null, frameName: '<entry>', frameDepth: 0 },
471
+ { kind: 'variable', name: 'metadata', value: { version: '1.0' }, type: 'json', isConst: false, source: null, frameName: '<entry>', frameDepth: 0 },
472
+ ]);
473
+
474
+ // Global context same at top level
475
+ expect(state.globalContext).toEqual(state.localContext);
476
+
477
+ // Verify formatted context sorts const first
478
+ const formatted = formatContextForAI(state.localContext);
479
+ expect(formatted.variables).toEqual([
480
+ { kind: 'variable', name: 'API_BASE', value: 'https://api.example.com', type: 'text', isConst: true, source: null, frameName: '<entry>', frameDepth: 0 },
481
+ { kind: 'variable', name: 'CONFIG', value: { timeout: '30', retries: '3' }, type: 'json', isConst: true, source: null, frameName: '<entry>', frameDepth: 0 },
482
+ { kind: 'variable', name: 'userInput', value: 'hello world', type: 'text', isConst: false, source: null, frameName: '<entry>', frameDepth: 0 },
483
+ { kind: 'variable', name: 'counter', value: '0', type: 'text', isConst: false, source: null, frameName: '<entry>', frameDepth: 0 },
484
+ { kind: 'variable', name: 'metadata', value: { version: '1.0' }, type: 'json', isConst: false, source: null, frameName: '<entry>', frameDepth: 0 },
485
+ ]);
486
+ });
487
+
488
+ test('complex function with params, locals, and nested block', () => {
489
+ // Function with multiple parameters, local variables, and AI call inside nested block
490
+ const ast = parse(`
491
+ const SYSTEM_PROMPT = "You are a helpful assistant"
492
+ model m = { name: "test", apiKey: "key", url: "http://test" }
493
+
494
+ function processData(inputText: text, options: text) {
495
+ const FUNC_CONST = "function constant"
496
+ let normalized: text = "normalized"
497
+ let result: json = { status: "pending" }
498
+
499
+ if true {
500
+ let blockVar = "inside block"
501
+ let response = vibe "analyze {inputText}" m default
502
+ }
503
+ }
504
+
505
+ let output = processData("test input", "opts")
506
+ `);
507
+
508
+ let state = createInitialState(ast);
509
+ state = runUntilPause(state);
510
+
511
+ expect(state.status).toBe('awaiting_ai');
512
+
513
+ // Local context should only have current frame (function scope + block scope)
514
+ // Should NOT include model m (filtered out) or outer SYSTEM_PROMPT (different frame)
515
+ // Depth 1 = called from entry
516
+ // Note: function parameters now have explicit type annotations
517
+ expect(state.localContext).toEqual([
518
+ { kind: 'variable', name: 'inputText', value: 'test input', type: 'text', isConst: false, frameName: 'processData', frameDepth: 1, source: null },
519
+ { kind: 'variable', name: 'options', value: 'opts', type: 'text', isConst: false, frameName: 'processData', frameDepth: 1, source: null },
520
+ { kind: 'variable', name: 'FUNC_CONST', value: 'function constant', type: 'text', isConst: true, frameName: 'processData', frameDepth: 1, source: null },
521
+ { kind: 'variable', name: 'normalized', value: 'normalized', type: 'text', isConst: false, frameName: 'processData', frameDepth: 1, source: null },
522
+ { kind: 'variable', name: 'result', value: { status: 'pending' }, type: 'json', isConst: false, frameName: 'processData', frameDepth: 1, source: null },
523
+ { kind: 'variable', name: 'blockVar', value: 'inside block', type: 'text', isConst: false, frameName: 'processData', frameDepth: 1, source: null },
524
+ ]);
525
+
526
+ // Global context includes all frames: <entry> (depth 0) + function (depth 1), models filtered out
527
+ expect(state.globalContext).toEqual([
528
+ { kind: 'variable', name: 'SYSTEM_PROMPT', value: 'You are a helpful assistant', type: 'text', isConst: true, frameName: '<entry>', frameDepth: 0, source: null },
529
+ { kind: 'variable', name: 'inputText', value: 'test input', type: 'text', isConst: false, frameName: 'processData', frameDepth: 1, source: null },
530
+ { kind: 'variable', name: 'options', value: 'opts', type: 'text', isConst: false, frameName: 'processData', frameDepth: 1, source: null },
531
+ { kind: 'variable', name: 'FUNC_CONST', value: 'function constant', type: 'text', isConst: true, frameName: 'processData', frameDepth: 1, source: null },
532
+ { kind: 'variable', name: 'normalized', value: 'normalized', type: 'text', isConst: false, frameName: 'processData', frameDepth: 1, source: null },
533
+ { kind: 'variable', name: 'result', value: { status: 'pending' }, type: 'json', isConst: false, frameName: 'processData', frameDepth: 1, source: null },
534
+ { kind: 'variable', name: 'blockVar', value: 'inside block', type: 'text', isConst: false, frameName: 'processData', frameDepth: 1, source: null },
535
+ ]);
536
+
537
+ // Verify formatted global context preserves declaration order
538
+ const formatted = formatContextForAI(state.globalContext);
539
+ expect(formatted.variables).toEqual(state.globalContext);
540
+ });
541
+
542
+ test('context at multiple call depths via sequential do calls', () => {
543
+ // Multi-checkpoint test: verify context at each do call as we traverse the call stack
544
+ const ast = parse(`
545
+ const GLOBAL_CONST = "global"
546
+ model m = { name: "test", apiKey: "key", url: "http://test" }
547
+
548
+ function helper(value: text): text {
549
+ const HELPER_CONST = "helper const"
550
+ let helperVar = "helper value"
551
+ return vibe "helper work with {value}" m default
552
+ }
553
+
554
+ function main(input: text): text {
555
+ const MAIN_CONST = "main const"
556
+ let mainVar = "main value"
557
+ let mainResult = vibe "main work with {input}" m default
558
+ return helper(input)
559
+ }
560
+
561
+ let result = main("test")
562
+ `);
563
+
564
+ let state = createInitialState(ast);
565
+
566
+ // === Checkpoint 1: Inside main(), at first do call ===
567
+ state = runUntilPause(state);
568
+ expect(state.status).toBe('awaiting_ai');
569
+ // With unified interpolation, {input} is left as reference in prompt strings
570
+ // The AI sees the literal {input} and gets the value through context
571
+ expect(state.pendingAI?.prompt).toBe('main work with {input}');
572
+
573
+ // Local context: main's frame only (depth 1 = called from entry)
574
+ // Note: function parameters now have explicit type annotations
575
+ expect(state.localContext).toEqual([
576
+ { kind: 'variable', name: 'input', value: 'test', type: 'text', isConst: false, frameName: 'main', frameDepth: 1, source: null },
577
+ { kind: 'variable', name: 'MAIN_CONST', value: 'main const', type: 'text', isConst: true, frameName: 'main', frameDepth: 1, source: null },
578
+ { kind: 'variable', name: 'mainVar', value: 'main value', type: 'text', isConst: false, frameName: 'main', frameDepth: 1, source: null },
579
+ ]);
580
+
581
+ // Global context: <entry> (depth 0) + main function (depth 1), models filtered
582
+ expect(state.globalContext).toEqual([
583
+ { kind: 'variable', name: 'GLOBAL_CONST', value: 'global', type: 'text', isConst: true, frameName: '<entry>', frameDepth: 0, source: null },
584
+ { kind: 'variable', name: 'input', value: 'test', type: 'text', isConst: false, frameName: 'main', frameDepth: 1, source: null },
585
+ { kind: 'variable', name: 'MAIN_CONST', value: 'main const', type: 'text', isConst: true, frameName: 'main', frameDepth: 1, source: null },
586
+ { kind: 'variable', name: 'mainVar', value: 'main value', type: 'text', isConst: false, frameName: 'main', frameDepth: 1, source: null },
587
+ ]);
588
+
589
+ // Verify formatted context preserves declaration order at checkpoint 1
590
+ const formatted1 = formatContextForAI(state.globalContext);
591
+ expect(formatted1.variables).toEqual(state.globalContext);
592
+
593
+ // Resume and continue to next pause
594
+ state = resumeWithAIResponse(state, 'main response');
595
+
596
+ // === Checkpoint 2: Inside helper(), at second do call ===
597
+ state = runUntilPause(state);
598
+ expect(state.status).toBe('awaiting_ai');
599
+ // With unified interpolation, {value} is left as reference in prompt strings
600
+ expect(state.pendingAI?.prompt).toBe('helper work with {value}');
601
+
602
+ // Local context: helper's frame only (depth 2 = called from main which is called from entry)
603
+ // Note: function parameters now have explicit type annotations
604
+ expect(state.localContext).toEqual([
605
+ { kind: 'variable', name: 'value', value: 'test', type: 'text', isConst: false, frameName: 'helper', frameDepth: 2, source: null },
606
+ { kind: 'variable', name: 'HELPER_CONST', value: 'helper const', type: 'text', isConst: true, frameName: 'helper', frameDepth: 2, source: null },
607
+ { kind: 'variable', name: 'helperVar', value: 'helper value', type: 'text', isConst: false, frameName: 'helper', frameDepth: 2, source: null },
608
+ ]);
609
+
610
+ // Global context: <entry> (depth 0) + main (depth 1) + helper (depth 2), models filtered
611
+ // Note: mainResult now has the response from checkpoint 1, and prompt is included
612
+ expect(state.globalContext).toEqual([
613
+ { kind: 'variable', name: 'GLOBAL_CONST', value: 'global', type: 'text', isConst: true, frameName: '<entry>', frameDepth: 0, source: null },
614
+ { kind: 'variable', name: 'input', value: 'test', type: 'text', isConst: false, frameName: 'main', frameDepth: 1, source: null },
615
+ { kind: 'variable', name: 'MAIN_CONST', value: 'main const', type: 'text', isConst: true, frameName: 'main', frameDepth: 1, source: null },
616
+ { kind: 'variable', name: 'mainVar', value: 'main value', type: 'text', isConst: false, frameName: 'main', frameDepth: 1, source: null },
617
+ { kind: 'prompt', aiType: 'vibe', prompt: 'main work with {input}', response: 'main response', frameName: 'main', frameDepth: 1 },
618
+ { kind: 'variable', name: 'mainResult', value: 'main response', type: 'text', isConst: false, source: 'ai', frameName: 'main', frameDepth: 1 },
619
+ { kind: 'variable', name: 'value', value: 'test', type: 'text', isConst: false, frameName: 'helper', frameDepth: 2, source: null },
620
+ { kind: 'variable', name: 'HELPER_CONST', value: 'helper const', type: 'text', isConst: true, frameName: 'helper', frameDepth: 2, source: null },
621
+ { kind: 'variable', name: 'helperVar', value: 'helper value', type: 'text', isConst: false, frameName: 'helper', frameDepth: 2, source: null },
622
+ ]);
623
+
624
+ // Verify formatted context preserves declaration order at checkpoint 2
625
+ const formatted2 = formatContextForAI(state.globalContext);
626
+ expect(formatted2.variables).toEqual(state.globalContext);
627
+
628
+ // Verify formatted text with nested call stack (3 frames: entry=0, main=1, helper=2)
629
+ // All entries together, grouped by frame with indentation
630
+ // Entry is leftmost (least indented), deeper calls are more indented
631
+ // Response shown via variable assignment (not duplicated with prompt)
632
+ expect(formatted2.text).toBe(
633
+ `## VIBE Program Context
634
+ Variables from the VIBE language call stack.
635
+
636
+ <entry> (entry)
637
+ - GLOBAL_CONST (text): global
638
+
639
+ main (depth 1)
640
+ - input (text): test
641
+ - MAIN_CONST (text): main const
642
+ - mainVar (text): main value
643
+ --> vibe: "main work with {input}"
644
+ <-- mainResult (text): main response
645
+
646
+ helper (current scope)
647
+ - value (text): test
648
+ - HELPER_CONST (text): helper const
649
+ - helperVar (text): helper value`
650
+ );
651
+
652
+ // Resume and complete
653
+ state = resumeWithAIResponse(state, 'helper response');
654
+ state = runUntilPause(state);
655
+ expect(state.status).toBe('completed');
656
+ });
657
+
658
+ test('context with all type annotations and complex values', () => {
659
+ const ast = parse(`
660
+ const PROMPT: text = "analyze this data"
661
+ const CONFIG: json = { modelName: "gpt-4", temperature: "high" }
662
+ model ai = { name: "test", apiKey: "key", url: "http://test" }
663
+ let userMessage: text = "user says hello"
664
+ let data: json = { items: ["a", "b", "c"], count: "3" }
665
+ let untypedVar = "plain string"
666
+ let result = vibe "process" ai default
667
+ `);
668
+
669
+ let state = createInitialState(ast);
670
+ state = runUntilPause(state);
671
+
672
+ expect(state.status).toBe('awaiting_ai');
673
+
674
+ // Verify all variables with their types (model 'ai' filtered out)
675
+ expect(state.localContext).toEqual([
676
+ { kind: 'variable', name: 'PROMPT', value: 'analyze this data', type: 'text', isConst: true, source: null, frameName: '<entry>', frameDepth: 0 },
677
+ { kind: 'variable', name: 'CONFIG', value: { modelName: 'gpt-4', temperature: 'high' }, type: 'json', isConst: true, source: null, frameName: '<entry>', frameDepth: 0 },
678
+ { kind: 'variable', name: 'userMessage', value: 'user says hello', type: 'text', isConst: false, source: null, frameName: '<entry>', frameDepth: 0 },
679
+ { kind: 'variable', name: 'data', value: { items: ['a', 'b', 'c'], count: '3' }, type: 'json', isConst: false, source: null, frameName: '<entry>', frameDepth: 0 },
680
+ { kind: 'variable', name: 'untypedVar', value: 'plain string', type: 'text', isConst: false, source: null, frameName: '<entry>', frameDepth: 0 },
681
+ ]);
682
+
683
+ // Verify formatted output - all variables together in declaration order
684
+ const formatted = formatContextForAI(state.localContext, { includeInstructions: false });
685
+ expect(formatted.text).toBe(
686
+ ` <entry> (current scope)
687
+ - PROMPT (text): analyze this data
688
+ - CONFIG (json): {"modelName":"gpt-4","temperature":"high"}
689
+ - userMessage (text): user says hello
690
+ - data (json): {"items":["a","b","c"],"count":"3"}
691
+ - untypedVar (text): plain string`
692
+ );
693
+ });
694
+
695
+ test('variable source changes from ai to undefined when reassigned', () => {
696
+ const ast = parse(`
697
+ model m = { name: "test", apiKey: "key", url: "http://test" }
698
+ let result: text = vibe "get initial value" m default
699
+ result = "overwritten by code"
700
+ `);
701
+
702
+ // Run until AI pause
703
+ let state = createInitialState(ast);
704
+ state = runUntilPause(state);
705
+ expect(state.status).toBe('awaiting_ai');
706
+
707
+ // Resume with AI response
708
+ state = resumeWithAIResponse(state, 'ai generated value');
709
+ state = runUntilPause(state);
710
+
711
+ // At this point, result should have source: 'ai'
712
+ // But execution continues and reassigns result
713
+ expect(state.status).toBe('completed');
714
+
715
+ // Check the variable's source is now null (reassigned by code)
716
+ const frame = state.callStack[0];
717
+ expect(frame.locals['result'].value).toBe('overwritten by code');
718
+ expect(frame.locals['result'].source).toBe(null);
719
+
720
+ // Verify context shows history: first AI-sourced entry, then code-sourced entry
721
+ // With snapshotting, both entries are preserved
722
+ const resultEntries = state.localContext.filter(
723
+ (e): e is ContextVariable => e.kind === 'variable' && e.name === 'result'
724
+ );
725
+ // First entry is AI-sourced
726
+ expect(resultEntries[0]?.source).toBe('ai');
727
+ expect(resultEntries[0]?.value).toBe('ai generated value');
728
+ // Second entry is code-sourced (no AI attribution)
729
+ expect(resultEntries[1]?.source).toBe(null);
730
+ expect(resultEntries[1]?.value).toBe('overwritten by code');
731
+ });
732
+
733
+ test('variable source is ai immediately after AI response assignment', () => {
734
+ const ast = parse(`
735
+ model m = { name: "test", apiKey: "key", url: "http://test" }
736
+ let result: text = vibe "get value" m default
737
+ `);
738
+
739
+ let state = createInitialState(ast);
740
+ state = runUntilPause(state);
741
+ expect(state.status).toBe('awaiting_ai');
742
+
743
+ // Resume with AI response
744
+ state = resumeWithAIResponse(state, 'ai response');
745
+ state = runUntilPause(state);
746
+ expect(state.status).toBe('completed');
747
+
748
+ // Check the variable's source is 'ai'
749
+ const frame = state.callStack[0];
750
+ expect(frame.locals['result'].value).toBe('ai response');
751
+ expect(frame.locals['result'].source).toBe('ai');
752
+
753
+ // Verify context shows AI attribution
754
+ const resultEntry = state.localContext.find(
755
+ (e): e is ContextVariable => e.kind === 'variable' && e.name === 'result'
756
+ );
757
+ expect(resultEntry?.source).toBe('ai');
758
+ });
759
+ });
760
+
761
+ describe('Tool Call Context Formatting', () => {
762
+ // These are unit tests for formatContextForAI - they directly set up orderedEntries
763
+ // to test the formatter in isolation. For full integration tests that actually
764
+ // execute tools through the AI provider, see ai-tool-integration.test.ts
765
+
766
+ test('tool call with result is formatted correctly', () => {
767
+ const ast = parse('let x = "test"');
768
+ let state = createInitialState(ast);
769
+ state = runUntilPause(state);
770
+
771
+ // Set up tool call entry directly to test formatter
772
+ const frame = state.callStack[state.callStack.length - 1];
773
+ frame.orderedEntries.push({
774
+ kind: 'tool-call',
775
+ toolName: 'getWeather',
776
+ args: { city: 'Seattle' },
777
+ result: { temp: 55, condition: 'rainy' },
778
+ });
779
+
780
+ // Rebuild context
781
+ state = { ...state, localContext: buildLocalContext(state) };
782
+
783
+ const formatted = formatContextForAI(state.localContext, { includeInstructions: false });
784
+ expect(formatted.text).toBe(
785
+ ` <entry> (current scope)
786
+ - x (text): test
787
+ [tool] getWeather({"city":"Seattle"})
788
+ [result] {"temp":55,"condition":"rainy"}`
789
+ );
790
+ });
791
+
792
+ test('tool call with error is formatted correctly', () => {
793
+ const ast = parse('let x = "test"');
794
+ let state = createInitialState(ast);
795
+ state = runUntilPause(state);
796
+
797
+ // Add a failed tool call entry
798
+ const frame = state.callStack[state.callStack.length - 1];
799
+ frame.orderedEntries.push({
800
+ kind: 'tool-call',
801
+ toolName: 'readFile',
802
+ args: { path: '/nonexistent.txt' },
803
+ error: 'File not found',
804
+ });
805
+
806
+ // Rebuild context
807
+ state = { ...state, localContext: buildLocalContext(state) };
808
+
809
+ const formatted = formatContextForAI(state.localContext, { includeInstructions: false });
810
+ expect(formatted.text).toBe(
811
+ ` <entry> (current scope)
812
+ - x (text): test
813
+ [tool] readFile({"path":"/nonexistent.txt"})
814
+ [error] File not found`
815
+ );
816
+ });
817
+
818
+ test('multiple tool calls are formatted in order', () => {
819
+ const ast = parse('let x = "test"');
820
+ let state = createInitialState(ast);
821
+ state = runUntilPause(state);
822
+
823
+ // Add multiple tool call entries
824
+ const frame = state.callStack[state.callStack.length - 1];
825
+ frame.orderedEntries.push(
826
+ {
827
+ kind: 'tool-call',
828
+ toolName: 'fetch',
829
+ args: { url: 'https://api.example.com/data' },
830
+ result: { status: 'ok' },
831
+ },
832
+ {
833
+ kind: 'tool-call',
834
+ toolName: 'jsonParse',
835
+ args: { text: '{"key":"value"}' },
836
+ result: { key: 'value' },
837
+ }
838
+ );
839
+
840
+ // Rebuild context
841
+ state = { ...state, localContext: buildLocalContext(state) };
842
+
843
+ const formatted = formatContextForAI(state.localContext, { includeInstructions: false });
844
+ expect(formatted.text).toBe(
845
+ ` <entry> (current scope)
846
+ - x (text): test
847
+ [tool] fetch({"url":"https://api.example.com/data"})
848
+ [result] {"status":"ok"}
849
+ [tool] jsonParse({"text":"{\\"key\\":\\"value\\"}"})
850
+ [result] {"key":"value"}`
851
+ );
852
+ });
853
+
854
+ test('tool calls mixed with prompts are formatted correctly', () => {
855
+ const ast = parse('let x = "test"');
856
+ let state = createInitialState(ast);
857
+ state = runUntilPause(state);
858
+
859
+ // Add a tool call followed by a prompt and its result variable
860
+ const frame = state.callStack[state.callStack.length - 1];
861
+ frame.orderedEntries.push(
862
+ {
863
+ kind: 'tool-call',
864
+ toolName: 'getWeather',
865
+ args: { city: 'Seattle' },
866
+ result: { temp: 55 },
867
+ },
868
+ {
869
+ kind: 'prompt',
870
+ aiType: 'vibe' as const,
871
+ prompt: 'Summarize the weather',
872
+ response: 'It is 55 degrees in Seattle',
873
+ },
874
+ {
875
+ kind: 'variable',
876
+ name: 'summary',
877
+ value: 'It is 55 degrees in Seattle',
878
+ type: 'text',
879
+ isConst: false,
880
+ source: 'ai' as const,
881
+ }
882
+ );
883
+
884
+ // Rebuild context
885
+ state = { ...state, localContext: buildLocalContext(state) };
886
+
887
+ const formatted = formatContextForAI(state.localContext, { includeInstructions: false });
888
+ expect(formatted.text).toBe(
889
+ ` <entry> (current scope)
890
+ - x (text): test
891
+ [tool] getWeather({"city":"Seattle"})
892
+ [result] {"temp":55}
893
+ --> vibe: "Summarize the weather"
894
+ <-- summary (text): It is 55 degrees in Seattle`
895
+ );
896
+ });
897
+
898
+ test('tool call without result (pending) is formatted correctly', () => {
899
+ const ast = parse('let x = "test"');
900
+ let state = createInitialState(ast);
901
+ state = runUntilPause(state);
902
+
903
+ // Add a tool call without result (simulating pending state)
904
+ const frame = state.callStack[state.callStack.length - 1];
905
+ frame.orderedEntries.push({
906
+ kind: 'tool-call',
907
+ toolName: 'longRunningTask',
908
+ args: { input: 'data' },
909
+ // No result or error - pending
910
+ });
911
+
912
+ // Rebuild context
913
+ state = { ...state, localContext: buildLocalContext(state) };
914
+
915
+ const formatted = formatContextForAI(state.localContext, { includeInstructions: false });
916
+ expect(formatted.text).toBe(
917
+ ` <entry> (current scope)
918
+ - x (text): test
919
+ [tool] longRunningTask({"input":"data"})`
920
+ );
921
+ });
922
+
923
+ test('full AI call flow with tool calls shows complete context', () => {
924
+ // Simulates: vibe "What's the weather in Seattle and SF?" -> AI calls tools -> final response
925
+ // Note: model variables are filtered from context (they are config, not data)
926
+ const ast = parse(`
927
+ model m = { name: "test", apiKey: "key", url: "http://test" }
928
+ let weather: text = vibe "What's the weather in Seattle and San Francisco?" m default
929
+ `);
930
+
931
+ let state = createInitialState(ast);
932
+ state = runUntilPause(state);
933
+ expect(state.status).toBe('awaiting_ai');
934
+
935
+ // Simulate the AI response with tool rounds
936
+ // Round 1: AI calls two tools in parallel
937
+ const toolRounds = [
938
+ {
939
+ toolCalls: [
940
+ { id: 'call_1', toolName: 'getWeather', args: { city: 'Seattle' } },
941
+ { id: 'call_2', toolName: 'getWeather', args: { city: 'San Francisco' } },
942
+ ],
943
+ results: [
944
+ { toolCallId: 'call_1', result: { temp: 55, condition: 'rainy' } },
945
+ { toolCallId: 'call_2', result: { temp: 68, condition: 'sunny' } },
946
+ ],
947
+ },
948
+ ];
949
+
950
+ // Resume with AI response (after tool calls completed)
951
+ state = resumeWithAIResponse(
952
+ state,
953
+ 'Seattle is 55°F and rainy. San Francisco is 68°F and sunny.',
954
+ undefined, // no interaction log
955
+ toolRounds
956
+ );
957
+ state = runUntilPause(state);
958
+ expect(state.status).toBe('completed');
959
+
960
+ // Verify the variable was assigned
961
+ const frame = state.callStack[0];
962
+ expect(frame.locals['weather'].value).toBe('Seattle is 55°F and rainy. San Francisco is 68°F and sunny.');
963
+
964
+ // Verify the context shows: AI call → tool calls → response (via variable assignment)
965
+ const formatted = formatContextForAI(state.localContext, { includeInstructions: false });
966
+ expect(formatted.text).toBe(
967
+ ` <entry> (current scope)
968
+ --> vibe: "What's the weather in Seattle and San Francisco?"
969
+ [tool] getWeather({"city":"Seattle"})
970
+ [result] {"temp":55,"condition":"rainy"}
971
+ [tool] getWeather({"city":"San Francisco"})
972
+ [result] {"temp":68,"condition":"sunny"}
973
+ <-- weather (text): Seattle is 55°F and rainy. San Francisco is 68°F and sunny.`
974
+ );
975
+ });
976
+
977
+ test('multiple rounds of tool calls show in context', () => {
978
+ // Simulates: do -> AI calls tool -> AI calls another tool -> final response
979
+ // Note: model variables are filtered from context
980
+ const ast = parse(`
981
+ model m = { name: "test", apiKey: "key", url: "http://test" }
982
+ let result: text = vibe "Find user 123 and get their orders" m default
983
+ `);
984
+
985
+ let state = createInitialState(ast);
986
+ state = runUntilPause(state);
987
+ expect(state.status).toBe('awaiting_ai');
988
+
989
+ // Simulate multiple rounds of tool calls
990
+ const toolRounds = [
991
+ // Round 1: Get user info
992
+ {
993
+ toolCalls: [
994
+ { id: 'call_1', toolName: 'getUser', args: { id: 123 } },
995
+ ],
996
+ results: [
997
+ { toolCallId: 'call_1', result: { name: 'Alice', email: 'alice@example.com' } },
998
+ ],
999
+ },
1000
+ // Round 2: Get orders for that user
1001
+ {
1002
+ toolCalls: [
1003
+ { id: 'call_2', toolName: 'getOrders', args: { userId: 123 } },
1004
+ ],
1005
+ results: [
1006
+ // Note: JSON.stringify removes trailing zeros (149.50 -> 149.5)
1007
+ { toolCallId: 'call_2', result: [{ orderId: 'A1', total: 99.99 }, { orderId: 'A2', total: 149.5 }] },
1008
+ ],
1009
+ },
1010
+ ];
1011
+
1012
+ state = resumeWithAIResponse(
1013
+ state,
1014
+ 'Alice (alice@example.com) has 2 orders totaling $249.49.',
1015
+ undefined,
1016
+ toolRounds
1017
+ );
1018
+ state = runUntilPause(state);
1019
+ expect(state.status).toBe('completed');
1020
+
1021
+ // Verify context shows: AI call → tool calls → response (via variable assignment)
1022
+ const formatted = formatContextForAI(state.localContext, { includeInstructions: false });
1023
+ expect(formatted.text).toBe(
1024
+ ` <entry> (current scope)
1025
+ --> vibe: "Find user 123 and get their orders"
1026
+ [tool] getUser({"id":123})
1027
+ [result] {"name":"Alice","email":"alice@example.com"}
1028
+ [tool] getOrders({"userId":123})
1029
+ [result] [{"orderId":"A1","total":99.99},{"orderId":"A2","total":149.5}]
1030
+ <-- result (text): Alice (alice@example.com) has 2 orders totaling $249.49.`
1031
+ );
1032
+ });
1033
+
1034
+ test('tool call with error followed by retry shows in context', () => {
1035
+ // Note: model variables are filtered from context
1036
+ const ast = parse(`
1037
+ model m = { name: "test", apiKey: "key", url: "http://test" }
1038
+ let data: text = vibe "Read the config file" m default
1039
+ `);
1040
+
1041
+ let state = createInitialState(ast);
1042
+ state = runUntilPause(state);
1043
+ expect(state.status).toBe('awaiting_ai');
1044
+
1045
+ // Simulate: first tool call fails, AI retries with different path
1046
+ const toolRounds = [
1047
+ {
1048
+ toolCalls: [
1049
+ { id: 'call_1', toolName: 'readFile', args: { path: '/etc/config.json' } },
1050
+ ],
1051
+ results: [
1052
+ { toolCallId: 'call_1', error: 'Permission denied' },
1053
+ ],
1054
+ },
1055
+ {
1056
+ toolCalls: [
1057
+ { id: 'call_2', toolName: 'readFile', args: { path: './config.json' } },
1058
+ ],
1059
+ results: [
1060
+ { toolCallId: 'call_2', result: '{"setting": "value"}' },
1061
+ ],
1062
+ },
1063
+ ];
1064
+
1065
+ state = resumeWithAIResponse(
1066
+ state,
1067
+ 'Found config with setting=value',
1068
+ undefined,
1069
+ toolRounds
1070
+ );
1071
+ state = runUntilPause(state);
1072
+ expect(state.status).toBe('completed');
1073
+
1074
+ // Verify context shows: AI call → error → retry → response (via variable assignment)
1075
+ const formatted = formatContextForAI(state.localContext, { includeInstructions: false });
1076
+ expect(formatted.text).toBe(
1077
+ ` <entry> (current scope)
1078
+ --> vibe: "Read the config file"
1079
+ [tool] readFile({"path":"/etc/config.json"})
1080
+ [error] Permission denied
1081
+ [tool] readFile({"path":"./config.json"})
1082
+ [result] {"setting": "value"}
1083
+ <-- data (text): Found config with setting=value`
1084
+ );
1085
+ });
1086
+ });