@build-astron-co/nimbus 0.2.0

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 (313) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +628 -0
  3. package/bin/nimbus +38 -0
  4. package/package.json +80 -0
  5. package/src/__tests__/app.test.ts +76 -0
  6. package/src/__tests__/audit.test.ts +877 -0
  7. package/src/__tests__/circuit-breaker.test.ts +116 -0
  8. package/src/__tests__/cli-run.test.ts +115 -0
  9. package/src/__tests__/context-manager.test.ts +502 -0
  10. package/src/__tests__/context.test.ts +242 -0
  11. package/src/__tests__/enterprise.test.ts +401 -0
  12. package/src/__tests__/generator.test.ts +433 -0
  13. package/src/__tests__/hooks.test.ts +582 -0
  14. package/src/__tests__/init.test.ts +436 -0
  15. package/src/__tests__/intent-parser.test.ts +229 -0
  16. package/src/__tests__/llm-router.test.ts +209 -0
  17. package/src/__tests__/lsp.test.ts +293 -0
  18. package/src/__tests__/modes.test.ts +336 -0
  19. package/src/__tests__/permissions.test.ts +338 -0
  20. package/src/__tests__/serve.test.ts +275 -0
  21. package/src/__tests__/sessions.test.ts +227 -0
  22. package/src/__tests__/sharing.test.ts +288 -0
  23. package/src/__tests__/snapshots.test.ts +581 -0
  24. package/src/__tests__/state-db.test.ts +334 -0
  25. package/src/__tests__/stream-with-tools.test.ts +732 -0
  26. package/src/__tests__/subagents.test.ts +176 -0
  27. package/src/__tests__/system-prompt.test.ts +169 -0
  28. package/src/__tests__/tool-converter.test.ts +256 -0
  29. package/src/__tests__/tool-schemas.test.ts +397 -0
  30. package/src/__tests__/tools.test.ts +143 -0
  31. package/src/__tests__/version.test.ts +49 -0
  32. package/src/agent/compaction-agent.ts +227 -0
  33. package/src/agent/context-manager.ts +435 -0
  34. package/src/agent/context.ts +427 -0
  35. package/src/agent/deploy-preview.ts +426 -0
  36. package/src/agent/index.ts +68 -0
  37. package/src/agent/loop.ts +717 -0
  38. package/src/agent/modes.ts +429 -0
  39. package/src/agent/permissions.ts +466 -0
  40. package/src/agent/subagents/base.ts +116 -0
  41. package/src/agent/subagents/cost.ts +51 -0
  42. package/src/agent/subagents/explore.ts +42 -0
  43. package/src/agent/subagents/general.ts +54 -0
  44. package/src/agent/subagents/index.ts +102 -0
  45. package/src/agent/subagents/infra.ts +59 -0
  46. package/src/agent/subagents/security.ts +69 -0
  47. package/src/agent/system-prompt.ts +436 -0
  48. package/src/app.ts +122 -0
  49. package/src/audit/activity-log.ts +290 -0
  50. package/src/audit/compliance-checker.ts +540 -0
  51. package/src/audit/cost-tracker.ts +318 -0
  52. package/src/audit/index.ts +23 -0
  53. package/src/audit/security-scanner.ts +596 -0
  54. package/src/auth/guard.ts +75 -0
  55. package/src/auth/index.ts +56 -0
  56. package/src/auth/oauth.ts +455 -0
  57. package/src/auth/providers.ts +470 -0
  58. package/src/auth/sso.ts +113 -0
  59. package/src/auth/store.ts +505 -0
  60. package/src/auth/types.ts +187 -0
  61. package/src/build.ts +141 -0
  62. package/src/cli/index.ts +16 -0
  63. package/src/cli/init.ts +854 -0
  64. package/src/cli/openapi-spec.ts +356 -0
  65. package/src/cli/run.ts +237 -0
  66. package/src/cli/serve-auth.ts +80 -0
  67. package/src/cli/serve.ts +462 -0
  68. package/src/cli/web.ts +67 -0
  69. package/src/cli.ts +1417 -0
  70. package/src/clients/core-engine-client.ts +227 -0
  71. package/src/clients/enterprise-client.ts +334 -0
  72. package/src/clients/generator-client.ts +351 -0
  73. package/src/clients/git-client.ts +627 -0
  74. package/src/clients/github-client.ts +410 -0
  75. package/src/clients/helm-client.ts +504 -0
  76. package/src/clients/index.ts +80 -0
  77. package/src/clients/k8s-client.ts +497 -0
  78. package/src/clients/llm-client.ts +161 -0
  79. package/src/clients/rest-client.ts +130 -0
  80. package/src/clients/service-discovery.ts +33 -0
  81. package/src/clients/terraform-client.ts +482 -0
  82. package/src/clients/tools-client.ts +1843 -0
  83. package/src/clients/ws-client.ts +115 -0
  84. package/src/commands/analyze/index.ts +352 -0
  85. package/src/commands/apply/helm.ts +473 -0
  86. package/src/commands/apply/index.ts +213 -0
  87. package/src/commands/apply/k8s.ts +454 -0
  88. package/src/commands/apply/terraform.ts +582 -0
  89. package/src/commands/ask.ts +167 -0
  90. package/src/commands/audit/index.ts +238 -0
  91. package/src/commands/auth-cloud.ts +294 -0
  92. package/src/commands/auth-list.ts +134 -0
  93. package/src/commands/auth-profile.ts +121 -0
  94. package/src/commands/auth-status.ts +141 -0
  95. package/src/commands/aws/ec2.ts +501 -0
  96. package/src/commands/aws/iam.ts +397 -0
  97. package/src/commands/aws/index.ts +133 -0
  98. package/src/commands/aws/lambda.ts +396 -0
  99. package/src/commands/aws/rds.ts +439 -0
  100. package/src/commands/aws/s3.ts +439 -0
  101. package/src/commands/aws/vpc.ts +393 -0
  102. package/src/commands/aws-discover.ts +649 -0
  103. package/src/commands/aws-terraform.ts +805 -0
  104. package/src/commands/azure/aks.ts +376 -0
  105. package/src/commands/azure/functions.ts +253 -0
  106. package/src/commands/azure/index.ts +116 -0
  107. package/src/commands/azure/storage.ts +478 -0
  108. package/src/commands/azure/vm.ts +355 -0
  109. package/src/commands/billing/index.ts +256 -0
  110. package/src/commands/chat.ts +314 -0
  111. package/src/commands/config.ts +346 -0
  112. package/src/commands/cost/cloud-cost-estimator.ts +266 -0
  113. package/src/commands/cost/estimator.ts +79 -0
  114. package/src/commands/cost/index.ts +594 -0
  115. package/src/commands/cost/parsers/terraform.ts +273 -0
  116. package/src/commands/cost/parsers/types.ts +25 -0
  117. package/src/commands/cost/pricing/aws.ts +544 -0
  118. package/src/commands/cost/pricing/azure.ts +499 -0
  119. package/src/commands/cost/pricing/gcp.ts +396 -0
  120. package/src/commands/cost/pricing/index.ts +40 -0
  121. package/src/commands/demo.ts +250 -0
  122. package/src/commands/doctor.ts +794 -0
  123. package/src/commands/drift/index.ts +439 -0
  124. package/src/commands/explain.ts +277 -0
  125. package/src/commands/feedback.ts +389 -0
  126. package/src/commands/fix.ts +324 -0
  127. package/src/commands/fs/index.ts +402 -0
  128. package/src/commands/gcp/compute.ts +325 -0
  129. package/src/commands/gcp/functions.ts +271 -0
  130. package/src/commands/gcp/gke.ts +438 -0
  131. package/src/commands/gcp/iam.ts +344 -0
  132. package/src/commands/gcp/index.ts +129 -0
  133. package/src/commands/gcp/storage.ts +284 -0
  134. package/src/commands/generate-helm.ts +1249 -0
  135. package/src/commands/generate-k8s.ts +1560 -0
  136. package/src/commands/generate-terraform.ts +1460 -0
  137. package/src/commands/gh/index.ts +863 -0
  138. package/src/commands/git/index.ts +1343 -0
  139. package/src/commands/helm/index.ts +1126 -0
  140. package/src/commands/help.ts +539 -0
  141. package/src/commands/history.ts +142 -0
  142. package/src/commands/import.ts +868 -0
  143. package/src/commands/index.ts +367 -0
  144. package/src/commands/init.ts +1046 -0
  145. package/src/commands/k8s/index.ts +1137 -0
  146. package/src/commands/login.ts +631 -0
  147. package/src/commands/logout.ts +83 -0
  148. package/src/commands/onboarding.ts +228 -0
  149. package/src/commands/plan/display.ts +279 -0
  150. package/src/commands/plan/index.ts +599 -0
  151. package/src/commands/preview.ts +452 -0
  152. package/src/commands/questionnaire.ts +1270 -0
  153. package/src/commands/resume.ts +55 -0
  154. package/src/commands/team/index.ts +346 -0
  155. package/src/commands/template.ts +232 -0
  156. package/src/commands/tf/index.ts +1034 -0
  157. package/src/commands/upgrade.ts +550 -0
  158. package/src/commands/usage/index.ts +134 -0
  159. package/src/commands/version.ts +170 -0
  160. package/src/compat/index.ts +2 -0
  161. package/src/compat/runtime.ts +12 -0
  162. package/src/compat/sqlite.ts +107 -0
  163. package/src/config/index.ts +17 -0
  164. package/src/config/manager.ts +530 -0
  165. package/src/config/safety-policy.ts +358 -0
  166. package/src/config/schema.ts +125 -0
  167. package/src/config/types.ts +527 -0
  168. package/src/context/context-db.ts +199 -0
  169. package/src/demo/index.ts +349 -0
  170. package/src/demo/scenarios/full-journey.ts +229 -0
  171. package/src/demo/scenarios/getting-started.ts +127 -0
  172. package/src/demo/scenarios/helm-release.ts +341 -0
  173. package/src/demo/scenarios/k8s-deployment.ts +194 -0
  174. package/src/demo/scenarios/terraform-vpc.ts +170 -0
  175. package/src/demo/types.ts +92 -0
  176. package/src/engine/cost-estimator.ts +438 -0
  177. package/src/engine/diagram-generator.ts +256 -0
  178. package/src/engine/drift-detector.ts +902 -0
  179. package/src/engine/executor.ts +1035 -0
  180. package/src/engine/index.ts +76 -0
  181. package/src/engine/orchestrator.ts +636 -0
  182. package/src/engine/planner.ts +720 -0
  183. package/src/engine/safety.ts +743 -0
  184. package/src/engine/verifier.ts +770 -0
  185. package/src/enterprise/audit.ts +348 -0
  186. package/src/enterprise/auth.ts +270 -0
  187. package/src/enterprise/billing.ts +822 -0
  188. package/src/enterprise/index.ts +17 -0
  189. package/src/enterprise/teams.ts +443 -0
  190. package/src/generator/best-practices.ts +1608 -0
  191. package/src/generator/helm.ts +630 -0
  192. package/src/generator/index.ts +37 -0
  193. package/src/generator/intent-parser.ts +514 -0
  194. package/src/generator/kubernetes.ts +976 -0
  195. package/src/generator/terraform.ts +1867 -0
  196. package/src/history/index.ts +8 -0
  197. package/src/history/manager.ts +322 -0
  198. package/src/history/types.ts +34 -0
  199. package/src/hooks/config.ts +432 -0
  200. package/src/hooks/engine.ts +391 -0
  201. package/src/hooks/index.ts +4 -0
  202. package/src/llm/auth-bridge.ts +198 -0
  203. package/src/llm/circuit-breaker.ts +140 -0
  204. package/src/llm/config-loader.ts +201 -0
  205. package/src/llm/cost-calculator.ts +171 -0
  206. package/src/llm/index.ts +8 -0
  207. package/src/llm/model-aliases.ts +115 -0
  208. package/src/llm/provider-registry.ts +63 -0
  209. package/src/llm/providers/anthropic.ts +433 -0
  210. package/src/llm/providers/bedrock.ts +477 -0
  211. package/src/llm/providers/google.ts +405 -0
  212. package/src/llm/providers/ollama.ts +767 -0
  213. package/src/llm/providers/openai-compatible.ts +340 -0
  214. package/src/llm/providers/openai.ts +328 -0
  215. package/src/llm/providers/openrouter.ts +338 -0
  216. package/src/llm/router.ts +1035 -0
  217. package/src/llm/types.ts +232 -0
  218. package/src/lsp/client.ts +298 -0
  219. package/src/lsp/languages.ts +116 -0
  220. package/src/lsp/manager.ts +278 -0
  221. package/src/mcp/client.ts +402 -0
  222. package/src/mcp/index.ts +5 -0
  223. package/src/mcp/manager.ts +133 -0
  224. package/src/nimbus.ts +214 -0
  225. package/src/plugins/index.ts +27 -0
  226. package/src/plugins/loader.ts +334 -0
  227. package/src/plugins/manager.ts +376 -0
  228. package/src/plugins/types.ts +284 -0
  229. package/src/scanners/cicd-scanner.ts +258 -0
  230. package/src/scanners/cloud-scanner.ts +466 -0
  231. package/src/scanners/framework-scanner.ts +469 -0
  232. package/src/scanners/iac-scanner.ts +388 -0
  233. package/src/scanners/index.ts +539 -0
  234. package/src/scanners/language-scanner.ts +276 -0
  235. package/src/scanners/package-manager-scanner.ts +277 -0
  236. package/src/scanners/types.ts +172 -0
  237. package/src/sessions/manager.ts +365 -0
  238. package/src/sessions/types.ts +44 -0
  239. package/src/sharing/sync.ts +296 -0
  240. package/src/sharing/viewer.ts +97 -0
  241. package/src/snapshots/index.ts +2 -0
  242. package/src/snapshots/manager.ts +530 -0
  243. package/src/state/artifacts.ts +147 -0
  244. package/src/state/audit.ts +137 -0
  245. package/src/state/billing.ts +240 -0
  246. package/src/state/checkpoints.ts +117 -0
  247. package/src/state/config.ts +67 -0
  248. package/src/state/conversations.ts +14 -0
  249. package/src/state/credentials.ts +154 -0
  250. package/src/state/db.ts +58 -0
  251. package/src/state/index.ts +26 -0
  252. package/src/state/messages.ts +115 -0
  253. package/src/state/projects.ts +123 -0
  254. package/src/state/schema.ts +236 -0
  255. package/src/state/sessions.ts +147 -0
  256. package/src/state/teams.ts +200 -0
  257. package/src/telemetry.ts +108 -0
  258. package/src/tools/aws-ops.ts +952 -0
  259. package/src/tools/azure-ops.ts +579 -0
  260. package/src/tools/file-ops.ts +593 -0
  261. package/src/tools/gcp-ops.ts +625 -0
  262. package/src/tools/git-ops.ts +773 -0
  263. package/src/tools/github-ops.ts +799 -0
  264. package/src/tools/helm-ops.ts +943 -0
  265. package/src/tools/index.ts +17 -0
  266. package/src/tools/k8s-ops.ts +819 -0
  267. package/src/tools/schemas/converter.ts +184 -0
  268. package/src/tools/schemas/devops.ts +612 -0
  269. package/src/tools/schemas/index.ts +73 -0
  270. package/src/tools/schemas/standard.ts +1144 -0
  271. package/src/tools/schemas/types.ts +705 -0
  272. package/src/tools/terraform-ops.ts +862 -0
  273. package/src/types/ambient.d.ts +193 -0
  274. package/src/types/config.ts +83 -0
  275. package/src/types/drift.ts +116 -0
  276. package/src/types/enterprise.ts +335 -0
  277. package/src/types/index.ts +20 -0
  278. package/src/types/plan.ts +44 -0
  279. package/src/types/request.ts +65 -0
  280. package/src/types/response.ts +54 -0
  281. package/src/types/service.ts +51 -0
  282. package/src/ui/App.tsx +997 -0
  283. package/src/ui/DeployPreview.tsx +169 -0
  284. package/src/ui/Header.tsx +68 -0
  285. package/src/ui/InputBox.tsx +350 -0
  286. package/src/ui/MessageList.tsx +585 -0
  287. package/src/ui/PermissionPrompt.tsx +151 -0
  288. package/src/ui/StatusBar.tsx +158 -0
  289. package/src/ui/ToolCallDisplay.tsx +409 -0
  290. package/src/ui/chat-ui.ts +853 -0
  291. package/src/ui/index.ts +33 -0
  292. package/src/ui/ink/index.ts +711 -0
  293. package/src/ui/streaming.ts +176 -0
  294. package/src/ui/types.ts +57 -0
  295. package/src/utils/analytics.ts +72 -0
  296. package/src/utils/cost-warning.ts +27 -0
  297. package/src/utils/env.ts +46 -0
  298. package/src/utils/errors.ts +69 -0
  299. package/src/utils/event-bus.ts +38 -0
  300. package/src/utils/index.ts +24 -0
  301. package/src/utils/logger.ts +171 -0
  302. package/src/utils/rate-limiter.ts +121 -0
  303. package/src/utils/service-auth.ts +49 -0
  304. package/src/utils/validation.ts +53 -0
  305. package/src/version.ts +4 -0
  306. package/src/watcher/index.ts +163 -0
  307. package/src/wizard/approval.ts +383 -0
  308. package/src/wizard/index.ts +25 -0
  309. package/src/wizard/prompts.ts +338 -0
  310. package/src/wizard/types.ts +171 -0
  311. package/src/wizard/ui.ts +556 -0
  312. package/src/wizard/wizard.ts +304 -0
  313. package/tsconfig.json +24 -0
@@ -0,0 +1,502 @@
1
+ /**
2
+ * Tests for Context Manager + Compaction Agent
3
+ *
4
+ * Covers token estimation, context breakdown calculation, message
5
+ * selection for compaction, and message reassembly.
6
+ *
7
+ * @module __tests__/context-manager
8
+ */
9
+
10
+ import { describe, it, expect, beforeEach } from 'bun:test';
11
+ import { ContextManager, estimateTokens, estimateMessageTokens } from '../agent/context-manager';
12
+ import type { LLMMessage } from '../llm/types';
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // estimateTokens
16
+ // ---------------------------------------------------------------------------
17
+
18
+ describe('estimateTokens', () => {
19
+ it('should return 0 for empty string', () => {
20
+ expect(estimateTokens('')).toBe(0);
21
+ });
22
+
23
+ it('should estimate tokens from character count', () => {
24
+ // 5 chars / 4 = 1.25 -> ceil -> 2
25
+ expect(estimateTokens('hello')).toBe(2);
26
+ expect(estimateTokens('a'.repeat(100))).toBe(25);
27
+ expect(estimateTokens('a'.repeat(400))).toBe(100);
28
+ });
29
+
30
+ it('should round up for non-divisible lengths', () => {
31
+ // 7 chars / 4 = 1.75 -> ceil -> 2
32
+ expect(estimateTokens('abcdefg')).toBe(2);
33
+ // 1 char / 4 = 0.25 -> ceil -> 1
34
+ expect(estimateTokens('x')).toBe(1);
35
+ });
36
+ });
37
+
38
+ // ---------------------------------------------------------------------------
39
+ // estimateMessageTokens
40
+ // ---------------------------------------------------------------------------
41
+
42
+ describe('estimateMessageTokens', () => {
43
+ it('should estimate tokens for a simple message', () => {
44
+ const msg: LLMMessage = { role: 'user', content: 'Hello world' };
45
+ const tokens = estimateMessageTokens(msg);
46
+ // "Hello world" = 11 chars / 4 = 3 + 4 overhead = 7
47
+ expect(tokens).toBe(7);
48
+ });
49
+
50
+ it('should add overhead for role framing', () => {
51
+ const msg: LLMMessage = { role: 'assistant', content: '' };
52
+ // Empty content = 0 tokens + 4 overhead = 4
53
+ expect(estimateMessageTokens(msg)).toBe(4);
54
+ });
55
+
56
+ it('should account for tool calls', () => {
57
+ const msg: LLMMessage = {
58
+ role: 'assistant',
59
+ content: 'Let me read that file.',
60
+ toolCalls: [
61
+ {
62
+ id: 'tc1',
63
+ type: 'function',
64
+ function: {
65
+ name: 'read_file',
66
+ arguments: '{"path":"src/index.ts"}',
67
+ },
68
+ },
69
+ ],
70
+ };
71
+ const tokens = estimateMessageTokens(msg);
72
+ // Should be more than just the content tokens
73
+ const contentOnly = estimateTokens('Let me read that file.') + 4;
74
+ expect(tokens).toBeGreaterThan(contentOnly);
75
+ });
76
+
77
+ it('should handle multiple tool calls', () => {
78
+ const singleCall: LLMMessage = {
79
+ role: 'assistant',
80
+ content: 'Working.',
81
+ toolCalls: [
82
+ {
83
+ id: 'tc1',
84
+ type: 'function',
85
+ function: { name: 'read_file', arguments: '{"path":"a.ts"}' },
86
+ },
87
+ ],
88
+ };
89
+ const doubleCalls: LLMMessage = {
90
+ role: 'assistant',
91
+ content: 'Working.',
92
+ toolCalls: [
93
+ {
94
+ id: 'tc1',
95
+ type: 'function',
96
+ function: { name: 'read_file', arguments: '{"path":"a.ts"}' },
97
+ },
98
+ {
99
+ id: 'tc2',
100
+ type: 'function',
101
+ function: { name: 'write_file', arguments: '{"path":"b.ts","content":"x"}' },
102
+ },
103
+ ],
104
+ };
105
+ expect(estimateMessageTokens(doubleCalls)).toBeGreaterThan(estimateMessageTokens(singleCall));
106
+ });
107
+ });
108
+
109
+ // ---------------------------------------------------------------------------
110
+ // ContextManager
111
+ // ---------------------------------------------------------------------------
112
+
113
+ describe('ContextManager', () => {
114
+ let cm: ContextManager;
115
+
116
+ beforeEach(() => {
117
+ cm = new ContextManager({
118
+ maxContextTokens: 1000,
119
+ autoCompactThreshold: 0.85,
120
+ preserveRecentMessages: 3,
121
+ });
122
+ });
123
+
124
+ // -------------------------------------------------------------------------
125
+ // shouldCompact
126
+ // -------------------------------------------------------------------------
127
+
128
+ describe('shouldCompact', () => {
129
+ it('should return false when usage is below threshold', () => {
130
+ const systemPrompt = 'You are a helpful assistant.';
131
+ const messages: LLMMessage[] = [{ role: 'user', content: 'Hello' }];
132
+ expect(cm.shouldCompact(systemPrompt, messages, 50)).toBe(false);
133
+ });
134
+
135
+ it('should return true when usage exceeds threshold', () => {
136
+ // ~850 tokens from system prompt alone
137
+ const systemPrompt = 'x'.repeat(3400);
138
+ const messages: LLMMessage[] = [{ role: 'user', content: 'Hello' }];
139
+ // 850 + ~6 + 50 = 906 -> 90.6% of 1000 > 85%
140
+ expect(cm.shouldCompact(systemPrompt, messages, 50)).toBe(true);
141
+ });
142
+
143
+ it('should return false when exactly at threshold boundary', () => {
144
+ // With 1000 budget and 0.85 threshold, need >= 85% = 850 tokens
145
+ // 840 tokens from prompt = 3360 chars
146
+ const systemPrompt = 'x'.repeat(3340);
147
+ const messages: LLMMessage[] = [];
148
+ // ~835 + 0 + 0 = 835 -> 83.5% < 85%
149
+ expect(cm.shouldCompact(systemPrompt, messages, 0)).toBe(false);
150
+ });
151
+ });
152
+
153
+ // -------------------------------------------------------------------------
154
+ // calculateUsage
155
+ // -------------------------------------------------------------------------
156
+
157
+ describe('calculateUsage', () => {
158
+ it('should break down context usage', () => {
159
+ const systemPrompt = 'Base prompt. # NIMBUS.md\nProject instructions here.';
160
+ const messages: LLMMessage[] = [
161
+ { role: 'user', content: 'What is this project?' },
162
+ { role: 'assistant', content: 'It is a cloud tool.' },
163
+ ];
164
+ const breakdown = cm.calculateUsage(systemPrompt, messages, 100);
165
+
166
+ expect(breakdown.systemPrompt).toBeGreaterThan(0);
167
+ expect(breakdown.nimbusInstructions).toBeGreaterThan(0);
168
+ expect(breakdown.messages).toBeGreaterThan(0);
169
+ expect(breakdown.toolDefinitions).toBe(100);
170
+ expect(breakdown.total).toBe(
171
+ breakdown.systemPrompt +
172
+ breakdown.nimbusInstructions +
173
+ breakdown.messages +
174
+ breakdown.toolDefinitions
175
+ );
176
+ expect(breakdown.budget).toBe(1000);
177
+ expect(breakdown.usagePercent).toBeGreaterThanOrEqual(0);
178
+ expect(breakdown.usagePercent).toBeLessThanOrEqual(100);
179
+ });
180
+
181
+ it('should handle system prompt without NIMBUS.md', () => {
182
+ const systemPrompt = 'You are a helpful DevOps assistant.';
183
+ const messages: LLMMessage[] = [];
184
+ const breakdown = cm.calculateUsage(systemPrompt, messages, 0);
185
+
186
+ expect(breakdown.nimbusInstructions).toBe(0);
187
+ expect(breakdown.systemPrompt).toBeGreaterThan(0);
188
+ });
189
+
190
+ it('should report 0% for zero budget', () => {
191
+ const zeroCm = new ContextManager({ maxContextTokens: 0 });
192
+ const breakdown = zeroCm.calculateUsage('prompt', [], 0);
193
+ expect(breakdown.usagePercent).toBe(0);
194
+ });
195
+ });
196
+
197
+ // -------------------------------------------------------------------------
198
+ // selectPreservedMessages
199
+ // -------------------------------------------------------------------------
200
+
201
+ describe('selectPreservedMessages', () => {
202
+ it('should preserve all messages when count <= threshold', () => {
203
+ const messages: LLMMessage[] = [
204
+ { role: 'user', content: 'A' },
205
+ { role: 'assistant', content: 'B' },
206
+ ];
207
+ const { preserved, toSummarize } = cm.selectPreservedMessages(messages);
208
+ expect(preserved).toHaveLength(2);
209
+ expect(toSummarize).toHaveLength(0);
210
+ });
211
+
212
+ it('should split messages when count > threshold', () => {
213
+ const messages: LLMMessage[] = [
214
+ { role: 'user', content: 'First' }, // preserved (first)
215
+ { role: 'assistant', content: 'Second' }, // summarize
216
+ { role: 'user', content: 'Third' }, // summarize
217
+ { role: 'assistant', content: 'Fourth' }, // summarize
218
+ { role: 'user', content: 'Fifth' }, // summarize
219
+ { role: 'assistant', content: 'Sixth' }, // preserved (recent)
220
+ { role: 'user', content: 'Seventh' }, // preserved (recent)
221
+ { role: 'assistant', content: 'Eighth' }, // preserved (recent)
222
+ ];
223
+ const { preserved, toSummarize } = cm.selectPreservedMessages(messages);
224
+ expect(preserved).toHaveLength(4); // first + 3 recent
225
+ expect(toSummarize).toHaveLength(4);
226
+ expect(preserved[0].content).toBe('First');
227
+ expect(preserved[1].content).toBe('Sixth');
228
+ });
229
+
230
+ it('should preserve summary blocks from previous compactions', () => {
231
+ const messages: LLMMessage[] = [
232
+ { role: 'user', content: 'First' },
233
+ {
234
+ role: 'user',
235
+ content: '[Context Summary] Previous summary here.',
236
+ },
237
+ { role: 'assistant', content: 'Middle' },
238
+ { role: 'user', content: 'Recent1' },
239
+ { role: 'assistant', content: 'Recent2' },
240
+ { role: 'user', content: 'Recent3' },
241
+ ];
242
+ const { preserved } = cm.selectPreservedMessages(messages);
243
+ const summaryMsg = preserved.find(
244
+ m => typeof m.content === 'string' && m.content.startsWith('[Context Summary]')
245
+ );
246
+ expect(summaryMsg).toBeDefined();
247
+ });
248
+
249
+ it('should preserve tool messages near the recent window', () => {
250
+ const messages: LLMMessage[] = [
251
+ { role: 'user', content: 'First' },
252
+ { role: 'assistant', content: 'A' },
253
+ { role: 'user', content: 'B' },
254
+ { role: 'assistant', content: 'C' },
255
+ {
256
+ role: 'tool',
257
+ content: 'tool output',
258
+ toolCallId: 'tc1',
259
+ name: 'read_file',
260
+ },
261
+ { role: 'assistant', content: 'D' },
262
+ { role: 'user', content: 'E' },
263
+ { role: 'assistant', content: 'F' },
264
+ ];
265
+ const { preserved } = cm.selectPreservedMessages(messages);
266
+ const toolMsg = preserved.find(m => m.role === 'tool');
267
+ expect(toolMsg).toBeDefined();
268
+ });
269
+ });
270
+
271
+ // -------------------------------------------------------------------------
272
+ // buildCompactedMessages
273
+ // -------------------------------------------------------------------------
274
+
275
+ describe('buildCompactedMessages', () => {
276
+ it('should insert summary after first preserved message', () => {
277
+ const preserved: LLMMessage[] = [
278
+ { role: 'user', content: 'First' },
279
+ { role: 'assistant', content: 'Recent' },
280
+ ];
281
+ const summary = 'User asked about project setup.';
282
+ const result = cm.buildCompactedMessages(preserved, summary);
283
+
284
+ expect(result).toHaveLength(3);
285
+ expect(result[0].content).toBe('First');
286
+ expect(result[1].content).toContain('[Context Summary]');
287
+ expect(result[1].content).toContain(summary);
288
+ expect(result[1].role).toBe('user');
289
+ expect(result[2].content).toBe('Recent');
290
+ });
291
+
292
+ it('should handle empty preserved array', () => {
293
+ const result = cm.buildCompactedMessages([], 'Summary text');
294
+ expect(result).toHaveLength(1);
295
+ expect(result[0].content).toContain('[Context Summary]');
296
+ });
297
+
298
+ it('should handle single preserved message', () => {
299
+ const preserved: LLMMessage[] = [{ role: 'user', content: 'Only one' }];
300
+ const result = cm.buildCompactedMessages(preserved, 'Summary');
301
+ expect(result).toHaveLength(2);
302
+ expect(result[0].content).toBe('Only one');
303
+ expect(result[1].content).toContain('[Context Summary]');
304
+ });
305
+ });
306
+
307
+ // -------------------------------------------------------------------------
308
+ // getConfig / setMaxContextTokens
309
+ // -------------------------------------------------------------------------
310
+
311
+ describe('getConfig', () => {
312
+ it('should return current configuration', () => {
313
+ const config = cm.getConfig();
314
+ expect(config.maxContextTokens).toBe(1000);
315
+ expect(config.autoCompactThreshold).toBe(0.85);
316
+ expect(config.preserveRecentMessages).toBe(3);
317
+ });
318
+ });
319
+
320
+ describe('setMaxContextTokens', () => {
321
+ it('should update the max context tokens', () => {
322
+ cm.setMaxContextTokens(2000);
323
+ expect(cm.getConfig().maxContextTokens).toBe(2000);
324
+ });
325
+
326
+ it('should affect shouldCompact calculations', () => {
327
+ const systemPrompt = 'x'.repeat(3400); // ~850 tokens
328
+ const messages: LLMMessage[] = [];
329
+ // At 1000 budget: 85% threshold -> needs 850+ tokens -> should compact
330
+ expect(cm.shouldCompact(systemPrompt, messages, 50)).toBe(true);
331
+ // Increase budget: at 2000, 850/2000 = 42.5% < 85% -> should not compact
332
+ cm.setMaxContextTokens(2000);
333
+ expect(cm.shouldCompact(systemPrompt, messages, 50)).toBe(false);
334
+ });
335
+ });
336
+
337
+ // -------------------------------------------------------------------------
338
+ // Default options
339
+ // -------------------------------------------------------------------------
340
+
341
+ describe('default options', () => {
342
+ it('should use sensible defaults when no options are provided', () => {
343
+ const defaultCm = new ContextManager();
344
+ const config = defaultCm.getConfig();
345
+ expect(config.maxContextTokens).toBe(200_000);
346
+ expect(config.autoCompactThreshold).toBe(0.85);
347
+ expect(config.preserveRecentMessages).toBe(5);
348
+ });
349
+ });
350
+ });
351
+
352
+ // ---------------------------------------------------------------------------
353
+ // Slash Command Parsing (TUI /compact and /context)
354
+ // ---------------------------------------------------------------------------
355
+
356
+ describe('Slash Command Parsing', () => {
357
+ /** Simulates the command detection logic from App.tsx handleSubmit. */
358
+ function parseSlashCommand(text: string): {
359
+ command: string | null;
360
+ args?: string;
361
+ } {
362
+ const trimmed = text.trim();
363
+ if (trimmed === '/compact') {
364
+ return { command: 'compact' };
365
+ }
366
+ if (trimmed.startsWith('/compact ')) {
367
+ return {
368
+ command: 'compact',
369
+ args: trimmed.slice('/compact '.length).trim(),
370
+ };
371
+ }
372
+ if (trimmed === '/context') {
373
+ return { command: 'context' };
374
+ }
375
+ return { command: null };
376
+ }
377
+
378
+ describe('/compact command', () => {
379
+ it('should detect bare /compact', () => {
380
+ const result = parseSlashCommand('/compact');
381
+ expect(result.command).toBe('compact');
382
+ expect(result.args).toBeUndefined();
383
+ });
384
+
385
+ it('should detect /compact with focus area', () => {
386
+ const result = parseSlashCommand('/compact terraform changes');
387
+ expect(result.command).toBe('compact');
388
+ expect(result.args).toBe('terraform changes');
389
+ });
390
+
391
+ it('should handle /compact with leading/trailing whitespace', () => {
392
+ const result = parseSlashCommand(' /compact ');
393
+ expect(result.command).toBe('compact');
394
+ });
395
+
396
+ it('should not match /compaction or other prefixes', () => {
397
+ const result = parseSlashCommand('/compaction');
398
+ expect(result.command).toBeNull();
399
+ });
400
+
401
+ it('should not match text that contains /compact but does not start with it', () => {
402
+ const result = parseSlashCommand('please /compact this');
403
+ expect(result.command).toBeNull();
404
+ });
405
+ });
406
+
407
+ describe('/context command', () => {
408
+ it('should detect /context', () => {
409
+ const result = parseSlashCommand('/context');
410
+ expect(result.command).toBe('context');
411
+ });
412
+
413
+ it('should handle /context with whitespace', () => {
414
+ const result = parseSlashCommand(' /context ');
415
+ expect(result.command).toBe('context');
416
+ });
417
+
418
+ it('should not match /contextual', () => {
419
+ const result = parseSlashCommand('/contextual');
420
+ expect(result.command).toBeNull();
421
+ });
422
+ });
423
+
424
+ describe('non-commands', () => {
425
+ it('should not detect regular messages', () => {
426
+ expect(parseSlashCommand('hello world').command).toBeNull();
427
+ expect(parseSlashCommand('fix the CORS issue').command).toBeNull();
428
+ });
429
+
430
+ it('should not detect unknown slash commands', () => {
431
+ expect(parseSlashCommand('/unknown').command).toBeNull();
432
+ expect(parseSlashCommand('/help').command).toBeNull();
433
+ });
434
+ });
435
+ });
436
+
437
+ // ---------------------------------------------------------------------------
438
+ // Context Breakdown Formatting (for /context display)
439
+ // ---------------------------------------------------------------------------
440
+
441
+ describe('Context Breakdown Formatting', () => {
442
+ it('should format breakdown for display', () => {
443
+ const cm = new ContextManager({
444
+ maxContextTokens: 200_000,
445
+ autoCompactThreshold: 0.85,
446
+ });
447
+
448
+ const systemPrompt = 'Base prompt. # NIMBUS.md\nProject instructions here.';
449
+ const messages: LLMMessage[] = [
450
+ { role: 'user', content: 'What is this project?' },
451
+ { role: 'assistant', content: 'It is a cloud tool.' },
452
+ ];
453
+ const breakdown = cm.calculateUsage(systemPrompt, messages, 500);
454
+
455
+ // Verify the breakdown has all fields needed for the TUI display
456
+ expect(typeof breakdown.systemPrompt).toBe('number');
457
+ expect(typeof breakdown.nimbusInstructions).toBe('number');
458
+ expect(typeof breakdown.messages).toBe('number');
459
+ expect(typeof breakdown.toolDefinitions).toBe('number');
460
+ expect(typeof breakdown.total).toBe('number');
461
+ expect(typeof breakdown.budget).toBe('number');
462
+ expect(typeof breakdown.usagePercent).toBe('number');
463
+
464
+ // Total should be positive and within budget for this small example
465
+ expect(breakdown.total).toBeGreaterThan(0);
466
+ expect(breakdown.total).toBeLessThan(breakdown.budget);
467
+ expect(breakdown.usagePercent).toBeLessThan(100);
468
+ });
469
+
470
+ it('should produce a displayable format string', () => {
471
+ const breakdown = {
472
+ systemPrompt: 500,
473
+ nimbusInstructions: 200,
474
+ messages: 1500,
475
+ toolDefinitions: 300,
476
+ total: 2500,
477
+ budget: 200_000,
478
+ usagePercent: 1,
479
+ };
480
+
481
+ // Simulates the TUI formatting from App.tsx
482
+ const lines = [
483
+ 'Context Usage Breakdown:',
484
+ ` System prompt: ${breakdown.systemPrompt.toLocaleString()} tokens`,
485
+ ` NIMBUS.md: ${breakdown.nimbusInstructions.toLocaleString()} tokens`,
486
+ ` Messages: ${breakdown.messages.toLocaleString()} tokens`,
487
+ ` Tool definitions: ${breakdown.toolDefinitions.toLocaleString()} tokens`,
488
+ ` ─────────────────────────────`,
489
+ ` Total: ${breakdown.total.toLocaleString()} / ${breakdown.budget.toLocaleString()} tokens (${breakdown.usagePercent}%)`,
490
+ ];
491
+ const display = lines.join('\n');
492
+
493
+ expect(display).toContain('Context Usage Breakdown');
494
+ expect(display).toContain('System prompt');
495
+ expect(display).toContain('NIMBUS.md');
496
+ expect(display).toContain('Messages');
497
+ expect(display).toContain('Tool definitions');
498
+ expect(display).toContain('2,500');
499
+ expect(display).toContain('200,000');
500
+ expect(display).toContain('1%');
501
+ });
502
+ });