@build-astron-co/nimbus 0.2.0 → 0.4.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.
- package/bin/nimbus +26 -10
- package/bin/nimbus.cmd +41 -0
- package/bin/nimbus.mjs +70 -0
- package/completions/nimbus.bash +38 -0
- package/completions/nimbus.fish +48 -0
- package/completions/nimbus.zsh +81 -0
- package/dist/src/agent/compaction-agent.js +215 -0
- package/dist/src/agent/context-manager.js +385 -0
- package/dist/src/agent/context.js +322 -0
- package/dist/src/agent/deploy-preview.js +395 -0
- package/dist/src/agent/expand-files.js +95 -0
- package/dist/src/agent/index.js +18 -0
- package/dist/src/agent/loop.js +1535 -0
- package/dist/src/agent/modes.js +347 -0
- package/dist/src/agent/permissions.js +396 -0
- package/dist/src/agent/subagents/base.js +67 -0
- package/dist/src/agent/subagents/cost.js +45 -0
- package/dist/src/agent/subagents/explore.js +36 -0
- package/dist/src/agent/subagents/general.js +41 -0
- package/dist/src/agent/subagents/index.js +88 -0
- package/dist/src/agent/subagents/infra.js +52 -0
- package/dist/src/agent/subagents/security.js +60 -0
- package/dist/src/agent/system-prompt.js +860 -0
- package/dist/src/app.js +152 -0
- package/dist/src/audit/activity-log.js +209 -0
- package/dist/src/audit/compliance-checker.js +419 -0
- package/dist/src/audit/cost-tracker.js +231 -0
- package/dist/src/audit/index.js +10 -0
- package/dist/src/audit/security-scanner.js +490 -0
- package/dist/src/auth/guard.js +64 -0
- package/dist/src/auth/index.js +19 -0
- package/dist/src/auth/keychain.js +79 -0
- package/dist/src/auth/oauth.js +389 -0
- package/dist/src/auth/providers.js +415 -0
- package/dist/src/auth/sso.js +87 -0
- package/dist/src/auth/store.js +424 -0
- package/dist/src/auth/types.js +5 -0
- package/dist/src/cli/index.js +8 -0
- package/dist/src/cli/init.js +1048 -0
- package/dist/src/cli/openapi-spec.js +346 -0
- package/dist/src/cli/run.js +505 -0
- package/dist/src/cli/serve-auth.js +56 -0
- package/dist/src/cli/serve.js +432 -0
- package/dist/src/cli/web.js +50 -0
- package/dist/src/cli.js +1574 -0
- package/dist/src/clients/core-engine-client.js +156 -0
- package/dist/src/clients/enterprise-client.js +246 -0
- package/dist/src/clients/generator-client.js +219 -0
- package/dist/src/clients/git-client.js +367 -0
- package/dist/src/clients/github-client.js +229 -0
- package/dist/src/clients/helm-client.js +299 -0
- package/dist/src/clients/index.js +18 -0
- package/dist/src/clients/k8s-client.js +270 -0
- package/dist/src/clients/llm-client.js +119 -0
- package/dist/src/clients/rest-client.js +104 -0
- package/dist/src/clients/service-discovery.js +35 -0
- package/dist/src/clients/terraform-client.js +302 -0
- package/dist/src/clients/tools-client.js +1227 -0
- package/dist/src/clients/ws-client.js +93 -0
- package/dist/src/commands/alias.js +91 -0
- package/dist/src/commands/analyze/index.js +313 -0
- package/dist/src/commands/apply/helm.js +375 -0
- package/dist/src/commands/apply/index.js +176 -0
- package/dist/src/commands/apply/k8s.js +350 -0
- package/dist/src/commands/apply/terraform.js +465 -0
- package/dist/src/commands/ask.js +137 -0
- package/dist/src/commands/audit/index.js +322 -0
- package/dist/src/commands/auth-cloud.js +345 -0
- package/dist/src/commands/auth-list.js +112 -0
- package/dist/src/commands/auth-profile.js +104 -0
- package/dist/src/commands/auth-refresh.js +161 -0
- package/dist/src/commands/auth-status.js +122 -0
- package/dist/src/commands/aws/ec2.js +402 -0
- package/dist/src/commands/aws/iam.js +304 -0
- package/dist/src/commands/aws/index.js +108 -0
- package/dist/src/commands/aws/lambda.js +317 -0
- package/dist/src/commands/aws/rds.js +345 -0
- package/dist/src/commands/aws/s3.js +346 -0
- package/dist/src/commands/aws/vpc.js +302 -0
- package/dist/src/commands/aws-discover.js +413 -0
- package/dist/src/commands/aws-terraform.js +618 -0
- package/dist/src/commands/azure/aks.js +305 -0
- package/dist/src/commands/azure/functions.js +200 -0
- package/dist/src/commands/azure/index.js +93 -0
- package/dist/src/commands/azure/storage.js +378 -0
- package/dist/src/commands/azure/vm.js +291 -0
- package/dist/src/commands/billing/index.js +224 -0
- package/dist/src/commands/chat.js +259 -0
- package/dist/src/commands/completions.js +255 -0
- package/dist/src/commands/config.js +291 -0
- package/dist/src/commands/cost/cloud-cost-estimator.js +211 -0
- package/dist/src/commands/cost/estimator.js +73 -0
- package/dist/src/commands/cost/index.js +625 -0
- package/dist/src/commands/cost/parsers/terraform.js +234 -0
- package/dist/src/commands/cost/parsers/types.js +4 -0
- package/dist/src/commands/cost/pricing/aws.js +501 -0
- package/dist/src/commands/cost/pricing/azure.js +462 -0
- package/dist/src/commands/cost/pricing/gcp.js +359 -0
- package/dist/src/commands/cost/pricing/index.js +24 -0
- package/dist/src/commands/demo.js +196 -0
- package/dist/src/commands/deploy.js +215 -0
- package/dist/src/commands/doctor.js +1291 -0
- package/dist/src/commands/drift/index.js +674 -0
- package/dist/src/commands/explain.js +235 -0
- package/dist/src/commands/export.js +120 -0
- package/dist/src/commands/feedback.js +319 -0
- package/dist/src/commands/fix.js +263 -0
- package/dist/src/commands/fs/index.js +338 -0
- package/dist/src/commands/gcp/compute.js +266 -0
- package/dist/src/commands/gcp/functions.js +221 -0
- package/dist/src/commands/gcp/gke.js +357 -0
- package/dist/src/commands/gcp/iam.js +295 -0
- package/dist/src/commands/gcp/index.js +105 -0
- package/dist/src/commands/gcp/storage.js +232 -0
- package/dist/src/commands/generate-helm.js +1026 -0
- package/dist/src/commands/generate-k8s.js +1263 -0
- package/dist/src/commands/generate-terraform.js +1058 -0
- package/dist/src/commands/gh/index.js +663 -0
- package/dist/src/commands/git/index.js +1208 -0
- package/dist/src/commands/helm/index.js +985 -0
- package/dist/src/commands/help.js +639 -0
- package/dist/src/commands/history.js +120 -0
- package/dist/src/commands/import.js +782 -0
- package/dist/src/commands/incident.js +144 -0
- package/dist/src/commands/index.js +109 -0
- package/dist/src/commands/init.js +955 -0
- package/dist/src/commands/k8s/index.js +979 -0
- package/dist/src/commands/login.js +588 -0
- package/dist/src/commands/logout.js +61 -0
- package/dist/src/commands/logs.js +160 -0
- package/dist/src/commands/onboarding.js +382 -0
- package/dist/src/commands/pipeline.js +153 -0
- package/dist/src/commands/plan/display.js +216 -0
- package/dist/src/commands/plan/index.js +525 -0
- package/dist/src/commands/plugin.js +325 -0
- package/dist/src/commands/preview.js +356 -0
- package/dist/src/commands/profile.js +297 -0
- package/dist/src/commands/questionnaire.js +1021 -0
- package/dist/src/commands/resume.js +35 -0
- package/dist/src/commands/rollback.js +259 -0
- package/dist/src/commands/rollout.js +74 -0
- package/dist/src/commands/runbook.js +307 -0
- package/dist/src/commands/schedule.js +202 -0
- package/dist/src/commands/status.js +213 -0
- package/dist/src/commands/team/index.js +309 -0
- package/dist/src/commands/team-context.js +200 -0
- package/dist/src/commands/template.js +204 -0
- package/dist/src/commands/tf/index.js +989 -0
- package/dist/src/commands/upgrade.js +515 -0
- package/dist/src/commands/usage/index.js +118 -0
- package/dist/src/commands/version.js +145 -0
- package/dist/src/commands/watch.js +127 -0
- package/dist/src/compat/index.js +2 -0
- package/dist/src/compat/runtime.js +10 -0
- package/dist/src/compat/sqlite.js +144 -0
- package/dist/src/config/index.js +6 -0
- package/dist/src/config/manager.js +469 -0
- package/dist/src/config/mode-store.js +57 -0
- package/dist/src/config/profiles.js +66 -0
- package/dist/src/config/safety-policy.js +251 -0
- package/dist/src/config/schema.js +107 -0
- package/dist/src/config/types.js +311 -0
- package/dist/src/config/workspace-state.js +38 -0
- package/dist/src/context/context-db.js +138 -0
- package/dist/src/demo/index.js +295 -0
- package/dist/src/demo/scenarios/full-journey.js +226 -0
- package/dist/src/demo/scenarios/getting-started.js +124 -0
- package/dist/src/demo/scenarios/helm-release.js +334 -0
- package/dist/src/demo/scenarios/k8s-deployment.js +190 -0
- package/dist/src/demo/scenarios/terraform-vpc.js +167 -0
- package/dist/src/demo/types.js +6 -0
- package/dist/src/engine/cost-estimator.js +334 -0
- package/dist/src/engine/diagram-generator.js +192 -0
- package/dist/src/engine/drift-detector.js +688 -0
- package/dist/src/engine/executor.js +832 -0
- package/dist/src/engine/index.js +39 -0
- package/dist/src/engine/orchestrator.js +436 -0
- package/dist/src/engine/planner.js +616 -0
- package/dist/src/engine/safety.js +609 -0
- package/dist/src/engine/verifier.js +664 -0
- package/dist/src/enterprise/audit.js +241 -0
- package/dist/src/enterprise/auth.js +189 -0
- package/dist/src/enterprise/billing.js +512 -0
- package/dist/src/enterprise/index.js +16 -0
- package/dist/src/enterprise/teams.js +315 -0
- package/dist/src/generator/best-practices.js +1375 -0
- package/dist/src/generator/helm.js +495 -0
- package/dist/src/generator/index.js +11 -0
- package/dist/src/generator/intent-parser.js +420 -0
- package/dist/src/generator/kubernetes.js +773 -0
- package/dist/src/generator/terraform.js +1472 -0
- package/dist/src/history/index.js +6 -0
- package/dist/src/history/manager.js +199 -0
- package/dist/src/history/types.js +6 -0
- package/dist/src/hooks/config.js +318 -0
- package/dist/src/hooks/engine.js +317 -0
- package/dist/src/hooks/index.js +2 -0
- package/dist/src/llm/auth-bridge.js +157 -0
- package/dist/src/llm/circuit-breaker.js +116 -0
- package/dist/src/llm/config-loader.js +172 -0
- package/dist/src/llm/cost-calculator.js +137 -0
- package/dist/src/llm/index.js +7 -0
- package/dist/src/llm/model-aliases.js +99 -0
- package/dist/src/llm/provider-registry.js +57 -0
- package/dist/src/llm/providers/anthropic.js +430 -0
- package/dist/src/llm/providers/bedrock.js +409 -0
- package/dist/src/llm/providers/google.js +344 -0
- package/dist/src/llm/providers/ollama.js +661 -0
- package/dist/src/llm/providers/openai-compatible.js +289 -0
- package/dist/src/llm/providers/openai.js +284 -0
- package/dist/src/llm/providers/openrouter.js +293 -0
- package/dist/src/llm/router.js +844 -0
- package/dist/src/llm/types.js +69 -0
- package/dist/src/lsp/client.js +239 -0
- package/dist/src/lsp/languages.js +95 -0
- package/dist/src/lsp/manager.js +243 -0
- package/dist/src/mcp/client.js +289 -0
- package/dist/src/mcp/index.js +5 -0
- package/dist/src/mcp/manager.js +113 -0
- package/dist/src/nimbus.js +212 -0
- package/dist/src/plugins/index.js +13 -0
- package/dist/src/plugins/loader.js +280 -0
- package/dist/src/plugins/manager.js +282 -0
- package/dist/src/plugins/types.js +23 -0
- package/dist/src/scanners/cicd-scanner.js +230 -0
- package/dist/src/scanners/cloud-scanner.js +415 -0
- package/dist/src/scanners/framework-scanner.js +430 -0
- package/dist/src/scanners/iac-scanner.js +350 -0
- package/dist/src/scanners/index.js +454 -0
- package/dist/src/scanners/language-scanner.js +258 -0
- package/dist/src/scanners/package-manager-scanner.js +252 -0
- package/dist/src/scanners/types.js +6 -0
- package/dist/src/sessions/manager.js +395 -0
- package/dist/src/sessions/types.js +4 -0
- package/dist/src/sharing/sync.js +238 -0
- package/dist/src/sharing/viewer.js +131 -0
- package/dist/src/snapshots/index.js +1 -0
- package/dist/src/snapshots/manager.js +432 -0
- package/dist/src/state/artifacts.js +94 -0
- package/dist/src/state/audit.js +73 -0
- package/dist/src/state/billing.js +126 -0
- package/dist/src/state/checkpoints.js +81 -0
- package/dist/src/state/config.js +58 -0
- package/dist/src/state/conversations.js +7 -0
- package/dist/src/state/credentials.js +96 -0
- package/dist/src/state/db.js +53 -0
- package/dist/src/state/index.js +23 -0
- package/dist/src/state/messages.js +76 -0
- package/dist/src/state/projects.js +92 -0
- package/dist/src/state/schema.js +233 -0
- package/dist/src/state/sessions.js +79 -0
- package/dist/src/state/teams.js +131 -0
- package/dist/src/telemetry.js +91 -0
- package/dist/src/tools/aws-ops.js +747 -0
- package/dist/src/tools/azure-ops.js +491 -0
- package/dist/src/tools/file-ops.js +451 -0
- package/dist/src/tools/gcp-ops.js +559 -0
- package/dist/src/tools/git-ops.js +557 -0
- package/dist/src/tools/github-ops.js +460 -0
- package/dist/src/tools/helm-ops.js +634 -0
- package/dist/src/tools/index.js +16 -0
- package/dist/src/tools/k8s-ops.js +579 -0
- package/dist/src/tools/schemas/converter.js +129 -0
- package/dist/src/tools/schemas/devops.js +3319 -0
- package/dist/src/tools/schemas/index.js +19 -0
- package/dist/src/tools/schemas/standard.js +966 -0
- package/dist/src/tools/schemas/types.js +409 -0
- package/dist/src/tools/spawn-exec.js +109 -0
- package/dist/src/tools/terraform-ops.js +627 -0
- package/dist/src/types/config.js +1 -0
- package/dist/src/types/drift.js +4 -0
- package/dist/src/types/enterprise.js +5 -0
- package/dist/src/types/index.js +14 -0
- package/dist/src/types/plan.js +1 -0
- package/dist/src/types/request.js +1 -0
- package/dist/src/types/response.js +1 -0
- package/dist/src/types/service.js +1 -0
- package/dist/src/ui/App.js +1672 -0
- package/dist/src/ui/DeployPreview.js +60 -0
- package/dist/src/ui/FileDiffModal.js +108 -0
- package/dist/src/ui/Header.js +46 -0
- package/dist/src/ui/HelpModal.js +9 -0
- package/dist/src/ui/InputBox.js +408 -0
- package/dist/src/ui/MessageList.js +795 -0
- package/dist/src/ui/PermissionPrompt.js +72 -0
- package/dist/src/ui/StatusBar.js +109 -0
- package/dist/src/ui/TerminalPane.js +31 -0
- package/dist/src/ui/ToolCallDisplay.js +303 -0
- package/dist/src/ui/TreePane.js +83 -0
- package/dist/src/ui/chat-ui.js +721 -0
- package/dist/src/ui/index.js +11 -0
- package/dist/src/ui/ink/index.js +1325 -0
- package/dist/src/ui/streaming.js +137 -0
- package/dist/src/ui/theme.js +78 -0
- package/dist/src/ui/types.js +7 -0
- package/dist/src/utils/analytics.js +61 -0
- package/dist/src/utils/cost-warning.js +25 -0
- package/dist/src/utils/env.js +42 -0
- package/dist/src/utils/errors.js +54 -0
- package/dist/src/utils/event-bus.js +22 -0
- package/dist/src/utils/index.js +16 -0
- package/dist/src/utils/logger.js +150 -0
- package/dist/src/utils/rate-limiter.js +90 -0
- package/dist/src/utils/service-auth.js +36 -0
- package/dist/src/utils/validation.js +39 -0
- package/dist/src/version.js +3 -0
- package/dist/src/watcher/index.js +192 -0
- package/dist/src/wizard/approval.js +275 -0
- package/dist/src/wizard/index.js +13 -0
- package/dist/src/wizard/prompts.js +273 -0
- package/dist/src/wizard/types.js +4 -0
- package/dist/src/wizard/ui.js +453 -0
- package/dist/src/wizard/wizard.js +227 -0
- package/package.json +31 -23
- package/src/__tests__/alias.test.ts +133 -0
- package/src/__tests__/app.test.ts +1 -1
- package/src/__tests__/audit.test.ts +1 -1
- package/src/__tests__/circuit-breaker.test.ts +1 -1
- package/src/__tests__/cli-run.test.ts +237 -1
- package/src/__tests__/compat-sqlite.test.ts +68 -0
- package/src/__tests__/context-manager.test.ts +131 -1
- package/src/__tests__/context.test.ts +1 -1
- package/src/__tests__/devops-terminal-gaps.test.ts +718 -0
- package/src/__tests__/doctor.test.ts +48 -0
- package/src/__tests__/enterprise.test.ts +1 -1
- package/src/__tests__/export.test.ts +236 -0
- package/src/__tests__/gap-11-18-20.test.ts +958 -0
- package/src/__tests__/generator.test.ts +1 -1
- package/src/__tests__/helm-streaming.test.ts +127 -0
- package/src/__tests__/hooks.test.ts +1 -1
- package/src/__tests__/incident.test.ts +179 -0
- package/src/__tests__/init.test.ts +55 -4
- package/src/__tests__/intent-parser.test.ts +1 -1
- package/src/__tests__/llm-router.test.ts +1 -1
- package/src/__tests__/logs.test.ts +107 -0
- package/src/__tests__/loop-errors.test.ts +244 -0
- package/src/__tests__/lsp.test.ts +1 -1
- package/src/__tests__/modes.test.ts +1 -1
- package/src/__tests__/perf-optimizations.test.ts +847 -0
- package/src/__tests__/permissions.test.ts +1 -1
- package/src/__tests__/pipeline.test.ts +50 -0
- package/src/__tests__/polish-phase3.test.ts +340 -0
- package/src/__tests__/profile.test.ts +237 -0
- package/src/__tests__/rollback.test.ts +83 -0
- package/src/__tests__/runbook.test.ts +219 -0
- package/src/__tests__/schedule.test.ts +206 -0
- package/src/__tests__/serve.test.ts +1 -1
- package/src/__tests__/sessions.test.ts +96 -1
- package/src/__tests__/sharing.test.ts +53 -1
- package/src/__tests__/snapshots.test.ts +1 -1
- package/src/__tests__/standalone-migration.test.ts +199 -0
- package/src/__tests__/state-db.test.ts +1 -1
- package/src/__tests__/status.test.ts +158 -0
- package/src/__tests__/stream-with-tools.test.ts +71 -25
- package/src/__tests__/subagents.test.ts +1 -1
- package/src/__tests__/system-prompt.test.ts +82 -3
- package/src/__tests__/terminal-gap-v2.test.ts +395 -0
- package/src/__tests__/terminal-parity.test.ts +393 -0
- package/src/__tests__/tf-apply.test.ts +187 -0
- package/src/__tests__/tool-converter.test.ts +1 -1
- package/src/__tests__/tool-schemas.test.ts +209 -4
- package/src/__tests__/tools.test.ts +4 -3
- package/src/__tests__/version-json.test.ts +184 -0
- package/src/__tests__/version.test.ts +1 -1
- package/src/__tests__/watch.test.ts +129 -0
- package/src/agent/compaction-agent.ts +40 -1
- package/src/agent/context-manager.ts +67 -3
- package/src/agent/deploy-preview.ts +62 -1
- package/src/agent/expand-files.ts +108 -0
- package/src/agent/loop.ts +1312 -31
- package/src/agent/permissions.ts +51 -4
- package/src/agent/system-prompt.ts +573 -19
- package/src/app.ts +58 -0
- package/src/audit/security-scanner.ts +45 -0
- package/src/auth/keychain.ts +82 -0
- package/src/auth/oauth.ts +15 -5
- package/src/cli/init.ts +378 -5
- package/src/cli/run.ts +407 -16
- package/src/cli/serve.ts +78 -1
- package/src/cli/web.ts +10 -6
- package/src/cli.ts +312 -1
- package/src/clients/service-discovery.ts +30 -25
- package/src/commands/alias.ts +100 -0
- package/src/commands/audit/index.ts +121 -2
- package/src/commands/auth-cloud.ts +113 -0
- package/src/commands/auth-refresh.ts +187 -0
- package/src/commands/aws-discover.ts +144 -251
- package/src/commands/aws-terraform.ts +68 -118
- package/src/commands/chat.ts +9 -3
- package/src/commands/completions.ts +268 -0
- package/src/commands/config.ts +26 -0
- package/src/commands/cost/index.ts +218 -2
- package/src/commands/deploy.ts +260 -0
- package/src/commands/doctor.ts +744 -152
- package/src/commands/drift/index.ts +371 -23
- package/src/commands/export.ts +146 -0
- package/src/commands/generate-k8s.ts +9 -61
- package/src/commands/generate-terraform.ts +191 -449
- package/src/commands/help.ts +212 -36
- package/src/commands/history.ts +8 -1
- package/src/commands/incident.ts +166 -0
- package/src/commands/init.ts +5 -0
- package/src/commands/login.ts +86 -1
- package/src/commands/logs.ts +167 -0
- package/src/commands/onboarding.ts +211 -34
- package/src/commands/pipeline.ts +186 -0
- package/src/commands/plugin.ts +398 -0
- package/src/commands/profile.ts +342 -0
- package/src/commands/questionnaire.ts +0 -98
- package/src/commands/resume.ts +26 -34
- package/src/commands/rollback.ts +315 -0
- package/src/commands/rollout.ts +88 -0
- package/src/commands/runbook.ts +346 -0
- package/src/commands/schedule.ts +236 -0
- package/src/commands/status.ts +252 -0
- package/src/commands/team-context.ts +220 -0
- package/src/commands/template.ts +58 -57
- package/src/commands/tf/index.ts +70 -11
- package/src/commands/upgrade.ts +57 -0
- package/src/commands/version.ts +54 -50
- package/src/commands/watch.ts +153 -0
- package/src/compat/runtime.ts +1 -1
- package/src/compat/sqlite.ts +75 -5
- package/src/config/mode-store.ts +62 -0
- package/src/config/profiles.ts +84 -0
- package/src/config/types.ts +83 -1
- package/src/config/workspace-state.ts +53 -0
- package/src/engine/cost-estimator.ts +52 -10
- package/src/engine/executor.ts +33 -2
- package/src/engine/planner.ts +68 -1
- package/src/generator/terraform.ts +8 -0
- package/src/history/manager.ts +2 -74
- package/src/hooks/engine.ts +5 -4
- package/src/llm/cost-calculator.ts +2 -2
- package/src/llm/providers/anthropic.ts +50 -21
- package/src/llm/router.ts +76 -7
- package/src/lsp/languages.ts +3 -0
- package/src/lsp/manager.ts +21 -5
- package/src/nimbus.ts +37 -18
- package/src/sessions/manager.ts +108 -1
- package/src/sharing/sync.ts +4 -0
- package/src/sharing/viewer.ts +66 -0
- package/src/tools/file-ops.ts +22 -0
- package/src/tools/schemas/devops.ts +3007 -117
- package/src/tools/schemas/standard.ts +5 -1
- package/src/tools/schemas/types.ts +31 -1
- package/src/tools/spawn-exec.ts +148 -0
- package/src/ui/App.tsx +1183 -66
- package/src/ui/DeployPreview.tsx +62 -57
- package/src/ui/FileDiffModal.tsx +162 -0
- package/src/ui/Header.tsx +87 -24
- package/src/ui/HelpModal.tsx +57 -0
- package/src/ui/InputBox.tsx +163 -10
- package/src/ui/MessageList.tsx +487 -40
- package/src/ui/PermissionPrompt.tsx +17 -5
- package/src/ui/StatusBar.tsx +122 -3
- package/src/ui/TerminalPane.tsx +84 -0
- package/src/ui/ToolCallDisplay.tsx +252 -18
- package/src/ui/TreePane.tsx +132 -0
- package/src/ui/chat-ui.ts +41 -44
- package/src/ui/ink/index.ts +771 -38
- package/src/ui/streaming.ts +1 -1
- package/src/ui/theme.ts +104 -0
- package/src/ui/types.ts +18 -0
- package/src/version.ts +1 -1
- package/src/watcher/index.ts +66 -15
- package/src/wizard/types.ts +1 -0
- package/src/wizard/ui.ts +1 -1
- package/tsconfig.json +2 -2
|
@@ -0,0 +1,958 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GAP-11, GAP-18, GAP-20 Tests
|
|
3
|
+
*
|
|
4
|
+
* GAP-11: Terraform plan → FileDiffBatch wiring
|
|
5
|
+
* - parseTerraformPlanOutput with various plan outputs
|
|
6
|
+
* - buildFileDiffBatchFromPlan output shape
|
|
7
|
+
* - requestFileDiff callback called after terraform plan
|
|
8
|
+
*
|
|
9
|
+
* GAP-18: IaC validation after writing .tf files
|
|
10
|
+
* - .tf file detection from tool name and path
|
|
11
|
+
* - terraform validate error injection into tool result
|
|
12
|
+
*
|
|
13
|
+
* GAP-20: Per-tool timeout from NIMBUS.md
|
|
14
|
+
* - parseToolTimeouts function parsing
|
|
15
|
+
* - timeout propagation to ToolExecuteContext
|
|
16
|
+
* - NIMBUS.md ## Tool Timeouts section parsing
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
20
|
+
import {
|
|
21
|
+
parseTerraformPlanOutput,
|
|
22
|
+
buildFileDiffBatchFromPlan,
|
|
23
|
+
type ResourceChange,
|
|
24
|
+
type DeployPreview,
|
|
25
|
+
} from '../agent/deploy-preview';
|
|
26
|
+
import { ToolRegistry } from '../tools/schemas/types';
|
|
27
|
+
import type { ToolDefinition, ToolExecuteContext } from '../tools/schemas/types';
|
|
28
|
+
import type { FileDiffDecision } from '../agent/loop';
|
|
29
|
+
import { z } from 'zod';
|
|
30
|
+
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// GAP-11 Tests: parseTerraformPlanOutput
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
describe('GAP-11 — parseTerraformPlanOutput', () => {
|
|
36
|
+
it('parses a create resource line', () => {
|
|
37
|
+
const output = ` # aws_instance.web will be created`;
|
|
38
|
+
const changes = parseTerraformPlanOutput(output);
|
|
39
|
+
expect(changes).toHaveLength(1);
|
|
40
|
+
expect(changes[0].resource).toBe('aws_instance.web');
|
|
41
|
+
expect(changes[0].action).toBe('create');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('parses an update resource line', () => {
|
|
45
|
+
const output = ` # aws_s3_bucket.data will be updated in-place`;
|
|
46
|
+
const changes = parseTerraformPlanOutput(output);
|
|
47
|
+
expect(changes).toHaveLength(1);
|
|
48
|
+
expect(changes[0].resource).toBe('aws_s3_bucket.data');
|
|
49
|
+
expect(changes[0].action).toBe('update');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('parses a destroy resource line', () => {
|
|
53
|
+
const output = ` # aws_security_group.old will be destroyed`;
|
|
54
|
+
const changes = parseTerraformPlanOutput(output);
|
|
55
|
+
expect(changes).toHaveLength(1);
|
|
56
|
+
expect(changes[0].resource).toBe('aws_security_group.old');
|
|
57
|
+
expect(changes[0].action).toBe('destroy');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('parses a replace resource line', () => {
|
|
61
|
+
const output = ` # aws_instance.app must be replaced`;
|
|
62
|
+
const changes = parseTerraformPlanOutput(output);
|
|
63
|
+
expect(changes).toHaveLength(1);
|
|
64
|
+
expect(changes[0].resource).toBe('aws_instance.app');
|
|
65
|
+
expect(changes[0].action).toBe('replace');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('parses multiple resources in a plan output', () => {
|
|
69
|
+
const output = [
|
|
70
|
+
' # aws_vpc.main will be created',
|
|
71
|
+
' # aws_subnet.public will be created',
|
|
72
|
+
' # aws_instance.old will be destroyed',
|
|
73
|
+
' # aws_instance.web will be updated in-place',
|
|
74
|
+
' # aws_rds_cluster.db must be replaced',
|
|
75
|
+
].join('\n');
|
|
76
|
+
const changes = parseTerraformPlanOutput(output);
|
|
77
|
+
expect(changes).toHaveLength(5);
|
|
78
|
+
expect(changes.filter(c => c.action === 'create')).toHaveLength(2);
|
|
79
|
+
expect(changes.filter(c => c.action === 'destroy')).toHaveLength(1);
|
|
80
|
+
expect(changes.filter(c => c.action === 'update')).toHaveLength(1);
|
|
81
|
+
expect(changes.filter(c => c.action === 'replace')).toHaveLength(1);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('falls back to summary line when no resource lines present', () => {
|
|
85
|
+
const output = `Plan: 2 to add, 1 to change, 1 to destroy.`;
|
|
86
|
+
const changes = parseTerraformPlanOutput(output);
|
|
87
|
+
expect(changes).toHaveLength(4);
|
|
88
|
+
expect(changes.filter(c => c.action === 'create')).toHaveLength(2);
|
|
89
|
+
expect(changes.filter(c => c.action === 'update')).toHaveLength(1);
|
|
90
|
+
expect(changes.filter(c => c.action === 'destroy')).toHaveLength(1);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('returns empty array for plan with no changes', () => {
|
|
94
|
+
const output = `No changes. Your infrastructure matches the configuration.`;
|
|
95
|
+
const changes = parseTerraformPlanOutput(output);
|
|
96
|
+
expect(changes).toHaveLength(0);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('ignores the summary line if individual resource lines were already parsed', () => {
|
|
100
|
+
const output = [
|
|
101
|
+
' # aws_instance.web will be created',
|
|
102
|
+
'Plan: 3 to add, 0 to change, 0 to destroy.',
|
|
103
|
+
].join('\n');
|
|
104
|
+
const changes = parseTerraformPlanOutput(output);
|
|
105
|
+
// Should only have 1 entry from the resource line, not 3+1
|
|
106
|
+
expect(changes).toHaveLength(1);
|
|
107
|
+
expect(changes[0].resource).toBe('aws_instance.web');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('handles module-prefixed resource names', () => {
|
|
111
|
+
const output = ` # module.vpc.aws_vpc.main will be created`;
|
|
112
|
+
const changes = parseTerraformPlanOutput(output);
|
|
113
|
+
expect(changes).toHaveLength(1);
|
|
114
|
+
expect(changes[0].resource).toBe('module.vpc.aws_vpc.main');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('parses plan with only destroy actions', () => {
|
|
118
|
+
const output = [
|
|
119
|
+
' # aws_route53_record.old will be destroyed',
|
|
120
|
+
' # aws_iam_role.legacy will be destroyed',
|
|
121
|
+
].join('\n');
|
|
122
|
+
const changes = parseTerraformPlanOutput(output);
|
|
123
|
+
expect(changes).toHaveLength(2);
|
|
124
|
+
expect(changes.every(c => c.action === 'destroy')).toBe(true);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
// GAP-11 Tests: buildFileDiffBatchFromPlan
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
|
|
132
|
+
describe('GAP-11 — buildFileDiffBatchFromPlan', () => {
|
|
133
|
+
function makePreview(changes: ResourceChange[]): DeployPreview {
|
|
134
|
+
return {
|
|
135
|
+
tool: 'terraform',
|
|
136
|
+
action: 'plan',
|
|
137
|
+
workdir: '/tmp/infra',
|
|
138
|
+
changes,
|
|
139
|
+
summary: {
|
|
140
|
+
toCreate: changes.filter(c => c.action === 'create').length,
|
|
141
|
+
toUpdate: changes.filter(c => c.action === 'update').length,
|
|
142
|
+
toDestroy: changes.filter(c => c.action === 'destroy').length,
|
|
143
|
+
toReplace: changes.filter(c => c.action === 'replace').length,
|
|
144
|
+
unchanged: 0,
|
|
145
|
+
},
|
|
146
|
+
rawOutput: '',
|
|
147
|
+
success: true,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
it('returns one entry per resource change', () => {
|
|
152
|
+
const preview = makePreview([
|
|
153
|
+
{ resource: 'aws_instance.web', action: 'create' },
|
|
154
|
+
{ resource: 'aws_s3_bucket.data', action: 'update' },
|
|
155
|
+
]);
|
|
156
|
+
const batch = buildFileDiffBatchFromPlan(preview);
|
|
157
|
+
expect(batch).toHaveLength(2);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('each entry has filePath, diff, and toolName', () => {
|
|
161
|
+
const preview = makePreview([{ resource: 'aws_vpc.main', action: 'create' }]);
|
|
162
|
+
const batch = buildFileDiffBatchFromPlan(preview);
|
|
163
|
+
expect(batch[0]).toHaveProperty('filePath');
|
|
164
|
+
expect(batch[0]).toHaveProperty('diff');
|
|
165
|
+
expect(batch[0]).toHaveProperty('toolName');
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('filePath equals the resource name', () => {
|
|
169
|
+
const preview = makePreview([{ resource: 'aws_instance.app', action: 'destroy' }]);
|
|
170
|
+
const batch = buildFileDiffBatchFromPlan(preview);
|
|
171
|
+
expect(batch[0].filePath).toBe('aws_instance.app');
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('toolName is "terraform" for all entries', () => {
|
|
175
|
+
const preview = makePreview([
|
|
176
|
+
{ resource: 'aws_vpc.main', action: 'create' },
|
|
177
|
+
{ resource: 'aws_subnet.pub', action: 'update' },
|
|
178
|
+
]);
|
|
179
|
+
const batch = buildFileDiffBatchFromPlan(preview);
|
|
180
|
+
expect(batch.every(b => b.toolName === 'terraform')).toBe(true);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('diff contains a unified diff header', () => {
|
|
184
|
+
const preview = makePreview([{ resource: 'aws_instance.web', action: 'create' }]);
|
|
185
|
+
const batch = buildFileDiffBatchFromPlan(preview);
|
|
186
|
+
expect(batch[0].diff).toContain('--- a/aws_instance.web');
|
|
187
|
+
expect(batch[0].diff).toContain('+++ b/aws_instance.web');
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('diff uses "+" symbol for create action', () => {
|
|
191
|
+
const preview = makePreview([{ resource: 'aws_lambda.fn', action: 'create' }]);
|
|
192
|
+
const batch = buildFileDiffBatchFromPlan(preview);
|
|
193
|
+
expect(batch[0].diff).toContain('+ aws_lambda.fn');
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('diff uses "-" symbol for destroy action', () => {
|
|
197
|
+
const preview = makePreview([{ resource: 'aws_iam_role.old', action: 'destroy' }]);
|
|
198
|
+
const batch = buildFileDiffBatchFromPlan(preview);
|
|
199
|
+
expect(batch[0].diff).toContain('- aws_iam_role.old');
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('diff uses "~" symbol for update action', () => {
|
|
203
|
+
const preview = makePreview([{ resource: 'aws_rds_instance.db', action: 'update' }]);
|
|
204
|
+
const batch = buildFileDiffBatchFromPlan(preview);
|
|
205
|
+
expect(batch[0].diff).toContain('~ aws_rds_instance.db');
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('diff includes details when present', () => {
|
|
209
|
+
const preview = makePreview([
|
|
210
|
+
{ resource: 'aws_instance.web', action: 'create', details: 'ami changed' },
|
|
211
|
+
]);
|
|
212
|
+
const batch = buildFileDiffBatchFromPlan(preview);
|
|
213
|
+
expect(batch[0].diff).toContain('ami changed');
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('returns empty array for plan with no changes', () => {
|
|
217
|
+
const preview = makePreview([]);
|
|
218
|
+
const batch = buildFileDiffBatchFromPlan(preview);
|
|
219
|
+
expect(batch).toHaveLength(0);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('handles replace action correctly', () => {
|
|
223
|
+
const preview = makePreview([{ resource: 'aws_instance.app', action: 'replace' }]);
|
|
224
|
+
const batch = buildFileDiffBatchFromPlan(preview);
|
|
225
|
+
expect(batch[0].diff).toContain('+/-');
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
// ---------------------------------------------------------------------------
|
|
230
|
+
// GAP-11 Tests: requestFileDiff callback integration
|
|
231
|
+
// ---------------------------------------------------------------------------
|
|
232
|
+
|
|
233
|
+
describe('GAP-11 — requestFileDiff callback with terraform plan', () => {
|
|
234
|
+
it('parseTerraformPlanOutput + buildFileDiffBatchFromPlan together produce requestable diffs', () => {
|
|
235
|
+
const planOutput = [
|
|
236
|
+
' # aws_instance.web will be created',
|
|
237
|
+
' # aws_security_group.main will be updated in-place',
|
|
238
|
+
].join('\n');
|
|
239
|
+
|
|
240
|
+
const changes = parseTerraformPlanOutput(planOutput);
|
|
241
|
+
expect(changes).toHaveLength(2);
|
|
242
|
+
|
|
243
|
+
// Build a minimal preview to feed buildFileDiffBatchFromPlan
|
|
244
|
+
const preview: DeployPreview = {
|
|
245
|
+
tool: 'terraform',
|
|
246
|
+
action: 'plan',
|
|
247
|
+
workdir: '/tmp',
|
|
248
|
+
changes,
|
|
249
|
+
summary: { toCreate: 1, toUpdate: 1, toDestroy: 0, toReplace: 0, unchanged: 0 },
|
|
250
|
+
rawOutput: planOutput,
|
|
251
|
+
success: true,
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
const batch = buildFileDiffBatchFromPlan(preview);
|
|
255
|
+
expect(batch).toHaveLength(2);
|
|
256
|
+
|
|
257
|
+
// Simulate the requestFileDiff callback being called for each
|
|
258
|
+
const calls: Array<[string, string, string]> = [];
|
|
259
|
+
const fakeRequestFileDiff = async (path: string, toolName: string, diff: string): Promise<FileDiffDecision> => {
|
|
260
|
+
calls.push([path, toolName, diff]);
|
|
261
|
+
return 'apply';
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
// Run through the batch as loop.ts would
|
|
265
|
+
const runBatch = async () => {
|
|
266
|
+
for (const file of batch) {
|
|
267
|
+
const decision = await fakeRequestFileDiff(file.filePath, file.toolName ?? 'terraform', file.diff ?? '');
|
|
268
|
+
if (decision === 'reject-all') break;
|
|
269
|
+
}
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
return runBatch().then(() => {
|
|
273
|
+
expect(calls).toHaveLength(2);
|
|
274
|
+
expect(calls[0][0]).toBe('aws_instance.web');
|
|
275
|
+
expect(calls[0][1]).toBe('terraform');
|
|
276
|
+
expect(calls[1][0]).toBe('aws_security_group.main');
|
|
277
|
+
});
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it('stops iteration on reject-all decision', async () => {
|
|
281
|
+
const changes: ResourceChange[] = [
|
|
282
|
+
{ resource: 'aws_vpc.main', action: 'create' },
|
|
283
|
+
{ resource: 'aws_subnet.pub', action: 'create' },
|
|
284
|
+
{ resource: 'aws_sg.main', action: 'create' },
|
|
285
|
+
];
|
|
286
|
+
const preview: DeployPreview = {
|
|
287
|
+
tool: 'terraform', action: 'plan', workdir: '/tmp',
|
|
288
|
+
changes, summary: { toCreate: 3, toUpdate: 0, toDestroy: 0, toReplace: 0, unchanged: 0 },
|
|
289
|
+
rawOutput: '', success: true,
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
const batch = buildFileDiffBatchFromPlan(preview);
|
|
293
|
+
const callCount = { n: 0 };
|
|
294
|
+
const fakeRequestFileDiff = async (_path: string, _toolName: string, _diff: string): Promise<FileDiffDecision> => {
|
|
295
|
+
callCount.n++;
|
|
296
|
+
return callCount.n === 1 ? 'reject-all' : 'apply';
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
for (const file of batch) {
|
|
300
|
+
const decision = await fakeRequestFileDiff(file.filePath, file.toolName ?? 'terraform', file.diff ?? '');
|
|
301
|
+
if (decision === 'reject-all') break;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Only 1 call should have been made before reject-all stopped iteration
|
|
305
|
+
expect(callCount.n).toBe(1);
|
|
306
|
+
});
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
// ---------------------------------------------------------------------------
|
|
310
|
+
// GAP-18 Tests: .tf file detection
|
|
311
|
+
// ---------------------------------------------------------------------------
|
|
312
|
+
|
|
313
|
+
describe('GAP-18 — .tf file detection logic', () => {
|
|
314
|
+
const FILE_WRITING_TOOLS = ['write_file', 'edit_file', 'multi_edit'];
|
|
315
|
+
|
|
316
|
+
function shouldValidateTf(toolName: string, filePath: string): boolean {
|
|
317
|
+
return FILE_WRITING_TOOLS.includes(toolName) && filePath.endsWith('.tf');
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
it('detects .tf extension for write_file with path', () => {
|
|
321
|
+
expect(shouldValidateTf('write_file', 'main.tf')).toBe(true);
|
|
322
|
+
expect(shouldValidateTf('write_file', '/infra/main.tf')).toBe(true);
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it('detects .tf extension for edit_file', () => {
|
|
326
|
+
expect(shouldValidateTf('edit_file', 'variables.tf')).toBe(true);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it('detects .tf extension for multi_edit', () => {
|
|
330
|
+
expect(shouldValidateTf('multi_edit', 'outputs.tf')).toBe(true);
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it('does NOT trigger for non-.tf files', () => {
|
|
334
|
+
expect(shouldValidateTf('write_file', 'main.py')).toBe(false);
|
|
335
|
+
expect(shouldValidateTf('write_file', 'values.yaml')).toBe(false);
|
|
336
|
+
expect(shouldValidateTf('write_file', 'Dockerfile')).toBe(false);
|
|
337
|
+
expect(shouldValidateTf('write_file', 'main.tfvars')).toBe(false);
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
it('does NOT trigger for non-file tools even with .tf in name', () => {
|
|
341
|
+
expect(shouldValidateTf('bash', 'something.tf')).toBe(false);
|
|
342
|
+
expect(shouldValidateTf('terraform', 'main.tf')).toBe(false);
|
|
343
|
+
expect(shouldValidateTf('read_file', 'main.tf')).toBe(false);
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it('handles empty path without error', () => {
|
|
347
|
+
expect(shouldValidateTf('write_file', '')).toBe(false);
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
it('recognizes nested .tf paths', () => {
|
|
351
|
+
expect(shouldValidateTf('write_file', '/home/user/project/modules/vpc/main.tf')).toBe(true);
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
it('does NOT match .tf.bak or other .tf-prefixed extensions', () => {
|
|
355
|
+
expect(shouldValidateTf('write_file', 'main.tf.bak')).toBe(false);
|
|
356
|
+
});
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
// ---------------------------------------------------------------------------
|
|
360
|
+
// GAP-18 Tests: terraform validate error injection
|
|
361
|
+
// ---------------------------------------------------------------------------
|
|
362
|
+
|
|
363
|
+
describe('GAP-18 — terraform validate error injection', () => {
|
|
364
|
+
it('produces correct error string from diagnostics', () => {
|
|
365
|
+
const diagnostics = [
|
|
366
|
+
{ severity: 'error', summary: 'Missing required argument', detail: '"name" is required' },
|
|
367
|
+
{ severity: 'error', summary: 'Invalid value', detail: '"region" must be a string' },
|
|
368
|
+
];
|
|
369
|
+
const errors = diagnostics
|
|
370
|
+
.filter(d => d.severity === 'error')
|
|
371
|
+
.map(d => ` ${d.summary}: ${d.detail}`)
|
|
372
|
+
.join('\n');
|
|
373
|
+
const suffix = `\n\nTerraform validation errors (please fix):\n${errors}`;
|
|
374
|
+
expect(suffix).toContain('Missing required argument');
|
|
375
|
+
expect(suffix).toContain('"name" is required');
|
|
376
|
+
expect(suffix).toContain('Invalid value');
|
|
377
|
+
expect(suffix).toContain('"region" must be a string');
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
it('filters out warning-level diagnostics', () => {
|
|
381
|
+
const diagnostics = [
|
|
382
|
+
{ severity: 'error', summary: 'Missing required argument', detail: '"name" is required' },
|
|
383
|
+
{ severity: 'warning', summary: 'Deprecated', detail: 'Use newer syntax' },
|
|
384
|
+
];
|
|
385
|
+
const errors = diagnostics
|
|
386
|
+
.filter(d => d.severity === 'error')
|
|
387
|
+
.map(d => ` ${d.summary}: ${d.detail}`)
|
|
388
|
+
.join('\n');
|
|
389
|
+
expect(errors).toContain('Missing required argument');
|
|
390
|
+
expect(errors).not.toContain('Deprecated');
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
it('produces no suffix when valid is true', () => {
|
|
394
|
+
const parsed = { valid: true, diagnostics: [] };
|
|
395
|
+
let toolContent = 'Success';
|
|
396
|
+
if (!parsed.valid && parsed.diagnostics && parsed.diagnostics.length > 0) {
|
|
397
|
+
toolContent += '\n\nTerraform validation errors (please fix):';
|
|
398
|
+
}
|
|
399
|
+
expect(toolContent).toBe('Success');
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
it('produces suffix when valid is false and errors exist', () => {
|
|
403
|
+
const parsed = {
|
|
404
|
+
valid: false,
|
|
405
|
+
diagnostics: [{ severity: 'error', summary: 'Error', detail: 'Something wrong' }],
|
|
406
|
+
};
|
|
407
|
+
let toolContent = 'File written';
|
|
408
|
+
if (!parsed.valid && parsed.diagnostics && parsed.diagnostics.length > 0) {
|
|
409
|
+
const errors = parsed.diagnostics
|
|
410
|
+
.filter(d => d.severity === 'error')
|
|
411
|
+
.map(d => ` ${d.summary}: ${d.detail}`)
|
|
412
|
+
.join('\n');
|
|
413
|
+
toolContent += `\n\nTerraform validation errors (please fix):\n${errors}`;
|
|
414
|
+
}
|
|
415
|
+
expect(toolContent).toContain('Terraform validation errors (please fix)');
|
|
416
|
+
expect(toolContent).toContain('File written');
|
|
417
|
+
expect(toolContent).toContain('Something wrong');
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
it('handles diagnostics with empty detail gracefully', () => {
|
|
421
|
+
const diagnostics = [
|
|
422
|
+
{ severity: 'error', summary: 'Syntax error', detail: '' },
|
|
423
|
+
];
|
|
424
|
+
const errors = diagnostics
|
|
425
|
+
.filter(d => d.severity === 'error')
|
|
426
|
+
.map(d => ` ${d.summary}: ${d.detail}`)
|
|
427
|
+
.join('\n');
|
|
428
|
+
expect(errors).toContain('Syntax error');
|
|
429
|
+
expect(errors).toContain(':');
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
it('handles empty diagnostics array without suffix', () => {
|
|
433
|
+
const parsed = { valid: false, diagnostics: [] as Array<{severity: string; summary: string; detail: string}> };
|
|
434
|
+
let toolContent = 'File written';
|
|
435
|
+
if (!parsed.valid && parsed.diagnostics && parsed.diagnostics.length > 0) {
|
|
436
|
+
const errors = parsed.diagnostics
|
|
437
|
+
.filter(d => d.severity === 'error')
|
|
438
|
+
.map(d => ` ${d.summary}: ${d.detail}`)
|
|
439
|
+
.join('\n');
|
|
440
|
+
toolContent += `\n\nTerraform validation errors (please fix):\n${errors}`;
|
|
441
|
+
}
|
|
442
|
+
// No errors even though valid is false (empty diagnostics)
|
|
443
|
+
expect(toolContent).toBe('File written');
|
|
444
|
+
});
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
// ---------------------------------------------------------------------------
|
|
448
|
+
// GAP-20 Tests: parseToolTimeouts function
|
|
449
|
+
// ---------------------------------------------------------------------------
|
|
450
|
+
|
|
451
|
+
// Inline reproduction of parseToolTimeouts from ink/index.ts so we can unit-test it
|
|
452
|
+
function parseToolTimeouts(nimbusMd: string): Record<string, number> {
|
|
453
|
+
const result: Record<string, number> = {};
|
|
454
|
+
const match = nimbusMd.match(/##\s+Tool Timeouts\s*\n([\s\S]*?)(?=##|$)/);
|
|
455
|
+
if (!match) return result;
|
|
456
|
+
for (const line of match[1].split('\n')) {
|
|
457
|
+
const m = line.match(/^\s*([a-z_]+)\s*:\s*(\d+)\s*$/);
|
|
458
|
+
if (m) result[m[1]] = parseInt(m[2], 10);
|
|
459
|
+
}
|
|
460
|
+
return result;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
describe('GAP-20 — parseToolTimeouts', () => {
|
|
464
|
+
it('returns empty object when no Tool Timeouts section', () => {
|
|
465
|
+
const nimbusMd = `## Project\nThis is a test project.\n\n## Instructions\nDo stuff.`;
|
|
466
|
+
const result = parseToolTimeouts(nimbusMd);
|
|
467
|
+
expect(result).toEqual({});
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
it('parses a single tool timeout', () => {
|
|
471
|
+
const nimbusMd = `## Tool Timeouts\nterraform: 300000\n`;
|
|
472
|
+
const result = parseToolTimeouts(nimbusMd);
|
|
473
|
+
expect(result).toEqual({ terraform: 300000 });
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
it('parses multiple tool timeouts', () => {
|
|
477
|
+
const nimbusMd = `## Tool Timeouts\nterraform: 600000\nkubectl: 120000\nhelm: 300000\n`;
|
|
478
|
+
const result = parseToolTimeouts(nimbusMd);
|
|
479
|
+
expect(result).toEqual({ terraform: 600000, kubectl: 120000, helm: 300000 });
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
it('parses tool timeouts with leading whitespace', () => {
|
|
483
|
+
const nimbusMd = `## Tool Timeouts\n terraform: 300000\n kubectl: 60000\n`;
|
|
484
|
+
const result = parseToolTimeouts(nimbusMd);
|
|
485
|
+
expect(result).toEqual({ terraform: 300000, kubectl: 60000 });
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
it('stops at the next ## section', () => {
|
|
489
|
+
const nimbusMd = `## Tool Timeouts\nterraform: 300000\n\n## Other Section\nkubectl: 999999\n`;
|
|
490
|
+
const result = parseToolTimeouts(nimbusMd);
|
|
491
|
+
// Only terraform should be parsed; kubectl appears after the next ##
|
|
492
|
+
expect(result).toHaveProperty('terraform', 300000);
|
|
493
|
+
expect(result).not.toHaveProperty('kubectl');
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
it('ignores non-matching lines in the section', () => {
|
|
497
|
+
const nimbusMd = `## Tool Timeouts\n# comment line\nterraform: 300000\nsome_text without colon\n`;
|
|
498
|
+
const result = parseToolTimeouts(nimbusMd);
|
|
499
|
+
expect(result).toEqual({ terraform: 300000 });
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
it('ignores lines with non-numeric values', () => {
|
|
503
|
+
const nimbusMd = `## Tool Timeouts\nterraform: fast\nkubectl: 120000\n`;
|
|
504
|
+
const result = parseToolTimeouts(nimbusMd);
|
|
505
|
+
// Only kubectl has a numeric value
|
|
506
|
+
expect(result).toEqual({ kubectl: 120000 });
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
it('ignores tool names with uppercase letters', () => {
|
|
510
|
+
const nimbusMd = `## Tool Timeouts\nTerraform: 300000\nkubectl: 120000\n`;
|
|
511
|
+
const result = parseToolTimeouts(nimbusMd);
|
|
512
|
+
// Terraform (capital T) doesn't match [a-z_]+ pattern
|
|
513
|
+
expect(result).toEqual({ kubectl: 120000 });
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
it('parses tool names with underscores', () => {
|
|
517
|
+
const nimbusMd = `## Tool Timeouts\ncloud_discover: 30000\nkubectl_context: 60000\n`;
|
|
518
|
+
const result = parseToolTimeouts(nimbusMd);
|
|
519
|
+
expect(result).toEqual({ cloud_discover: 30000, kubectl_context: 60000 });
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
it('handles Tool Timeouts section at end of file without trailing ##', () => {
|
|
523
|
+
const nimbusMd = `## Project\nMy project.\n\n## Tool Timeouts\nterraform: 450000`;
|
|
524
|
+
const result = parseToolTimeouts(nimbusMd);
|
|
525
|
+
expect(result).toEqual({ terraform: 450000 });
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
it('returns integer values (not floats)', () => {
|
|
529
|
+
const nimbusMd = `## Tool Timeouts\nterraform: 300000\n`;
|
|
530
|
+
const result = parseToolTimeouts(nimbusMd);
|
|
531
|
+
expect(Number.isInteger(result.terraform)).toBe(true);
|
|
532
|
+
});
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
// ---------------------------------------------------------------------------
|
|
536
|
+
// GAP-20 Tests: ToolExecuteContext timeout field
|
|
537
|
+
// ---------------------------------------------------------------------------
|
|
538
|
+
|
|
539
|
+
describe('GAP-20 — ToolExecuteContext.timeout field', () => {
|
|
540
|
+
it('ToolExecuteContext accepts a timeout field', async () => {
|
|
541
|
+
const { z } = await import('zod');
|
|
542
|
+
let capturedCtx: ToolExecuteContext | undefined;
|
|
543
|
+
|
|
544
|
+
const tool: ToolDefinition = {
|
|
545
|
+
name: 'test_timeout_tool',
|
|
546
|
+
description: 'A test tool that captures context',
|
|
547
|
+
inputSchema: z.object({ value: z.string() }),
|
|
548
|
+
permissionTier: 'auto_allow',
|
|
549
|
+
category: 'devops',
|
|
550
|
+
execute: async (_input: unknown, ctx?: ToolExecuteContext) => {
|
|
551
|
+
capturedCtx = ctx;
|
|
552
|
+
return { output: 'ok', isError: false };
|
|
553
|
+
},
|
|
554
|
+
};
|
|
555
|
+
|
|
556
|
+
const ctx: ToolExecuteContext = { timeout: 30000 };
|
|
557
|
+
await tool.execute({ value: 'test' }, ctx);
|
|
558
|
+
expect(capturedCtx?.timeout).toBe(30000);
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
it('ToolExecuteContext.timeout is optional', async () => {
|
|
562
|
+
const { z } = await import('zod');
|
|
563
|
+
let capturedCtx: ToolExecuteContext | undefined;
|
|
564
|
+
|
|
565
|
+
const tool: ToolDefinition = {
|
|
566
|
+
name: 'test_no_timeout_tool',
|
|
567
|
+
description: 'A test tool',
|
|
568
|
+
inputSchema: z.object({ value: z.string() }),
|
|
569
|
+
permissionTier: 'auto_allow',
|
|
570
|
+
category: 'devops',
|
|
571
|
+
execute: async (_input: unknown, ctx?: ToolExecuteContext) => {
|
|
572
|
+
capturedCtx = ctx;
|
|
573
|
+
return { output: 'ok', isError: false };
|
|
574
|
+
},
|
|
575
|
+
};
|
|
576
|
+
|
|
577
|
+
await tool.execute({ value: 'test' }, {});
|
|
578
|
+
expect(capturedCtx?.timeout).toBeUndefined();
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
it('ToolExecuteContext can have both onProgress and timeout', async () => {
|
|
582
|
+
const { z } = await import('zod');
|
|
583
|
+
let capturedCtx: ToolExecuteContext | undefined;
|
|
584
|
+
|
|
585
|
+
const tool: ToolDefinition = {
|
|
586
|
+
name: 'test_full_ctx_tool',
|
|
587
|
+
description: 'A test tool',
|
|
588
|
+
inputSchema: z.object({}),
|
|
589
|
+
permissionTier: 'auto_allow',
|
|
590
|
+
category: 'standard',
|
|
591
|
+
execute: async (_input: unknown, ctx?: ToolExecuteContext) => {
|
|
592
|
+
capturedCtx = ctx;
|
|
593
|
+
return { output: 'ok', isError: false };
|
|
594
|
+
},
|
|
595
|
+
};
|
|
596
|
+
|
|
597
|
+
const chunks: string[] = [];
|
|
598
|
+
const ctx: ToolExecuteContext = {
|
|
599
|
+
onProgress: (chunk) => chunks.push(chunk),
|
|
600
|
+
timeout: 45000,
|
|
601
|
+
};
|
|
602
|
+
await tool.execute({}, ctx);
|
|
603
|
+
expect(capturedCtx?.timeout).toBe(45000);
|
|
604
|
+
expect(capturedCtx?.onProgress).toBeDefined();
|
|
605
|
+
});
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
// ---------------------------------------------------------------------------
|
|
609
|
+
// GAP-20 Tests: toolTimeouts propagation in AgentLoopOptions
|
|
610
|
+
// ---------------------------------------------------------------------------
|
|
611
|
+
|
|
612
|
+
describe('GAP-20 — toolTimeouts in AgentLoopOptions (type check)', () => {
|
|
613
|
+
it('AgentLoopOptions type includes toolTimeouts field', async () => {
|
|
614
|
+
const { readFileSync } = await import('node:fs');
|
|
615
|
+
const { join } = await import('node:path');
|
|
616
|
+
const loopSrc = readFileSync(join(__dirname, '..', 'agent', 'loop.ts'), 'utf-8');
|
|
617
|
+
// Verify the toolTimeouts field is defined in AgentLoopOptions
|
|
618
|
+
expect(loopSrc).toContain('toolTimeouts?: Record<string, number>');
|
|
619
|
+
});
|
|
620
|
+
|
|
621
|
+
it('executeToolCall signature includes toolTimeouts parameter', async () => {
|
|
622
|
+
const { readFileSync } = await import('node:fs');
|
|
623
|
+
const { join } = await import('node:path');
|
|
624
|
+
const loopSrc = readFileSync(join(__dirname, '..', 'agent', 'loop.ts'), 'utf-8');
|
|
625
|
+
// Verify toolTimeouts is a parameter of executeToolCall
|
|
626
|
+
expect(loopSrc).toContain('toolTimeouts?: Record<string, number>');
|
|
627
|
+
// Verify the GAP-20 comment is present
|
|
628
|
+
expect(loopSrc).toContain('GAP-20');
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
it('loop.ts passes options.toolTimeouts to executeToolCall', async () => {
|
|
632
|
+
const { readFileSync } = await import('node:fs');
|
|
633
|
+
const { join } = await import('node:path');
|
|
634
|
+
const loopSrc = readFileSync(join(__dirname, '..', 'agent', 'loop.ts'), 'utf-8');
|
|
635
|
+
expect(loopSrc).toContain('options.toolTimeouts');
|
|
636
|
+
});
|
|
637
|
+
|
|
638
|
+
it('loop.ts builds toolCtx with timeout field', async () => {
|
|
639
|
+
const { readFileSync } = await import('node:fs');
|
|
640
|
+
const { join } = await import('node:path');
|
|
641
|
+
const loopSrc = readFileSync(join(__dirname, '..', 'agent', 'loop.ts'), 'utf-8');
|
|
642
|
+
expect(loopSrc).toContain('toolTimeouts?.[toolName]');
|
|
643
|
+
});
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
// ---------------------------------------------------------------------------
|
|
647
|
+
// GAP-20 Tests: devops.ts DEFAULT_TIMEOUT usage
|
|
648
|
+
// ---------------------------------------------------------------------------
|
|
649
|
+
|
|
650
|
+
describe('GAP-20 — devops.ts DEFAULT_TIMEOUT constant', () => {
|
|
651
|
+
it('DEFAULT_TIMEOUT constant is defined in devops.ts', async () => {
|
|
652
|
+
const { readFileSync } = await import('node:fs');
|
|
653
|
+
const { join } = await import('node:path');
|
|
654
|
+
const devopsSrc = readFileSync(join(__dirname, '..', 'tools', 'schemas', 'devops.ts'), 'utf-8');
|
|
655
|
+
expect(devopsSrc).toContain('const DEFAULT_TIMEOUT = 600_000');
|
|
656
|
+
});
|
|
657
|
+
|
|
658
|
+
it('terraform spawnExec uses ctx?.timeout ?? DEFAULT_TIMEOUT', async () => {
|
|
659
|
+
const { readFileSync } = await import('node:fs');
|
|
660
|
+
const { join } = await import('node:path');
|
|
661
|
+
const devopsSrc = readFileSync(join(__dirname, '..', 'tools', 'schemas', 'devops.ts'), 'utf-8');
|
|
662
|
+
expect(devopsSrc).toContain('ctx?.timeout ?? DEFAULT_TIMEOUT');
|
|
663
|
+
});
|
|
664
|
+
|
|
665
|
+
it('kubectl spawnExec uses ctx?.timeout override', async () => {
|
|
666
|
+
const { readFileSync } = await import('node:fs');
|
|
667
|
+
const { join } = await import('node:path');
|
|
668
|
+
const devopsSrc = readFileSync(join(__dirname, '..', 'tools', 'schemas', 'devops.ts'), 'utf-8');
|
|
669
|
+
// kubectl uses ctx?.timeout ?? defaultKubectlTimeoutMs
|
|
670
|
+
expect(devopsSrc).toContain('ctx?.timeout ?? defaultKubectlTimeoutMs');
|
|
671
|
+
});
|
|
672
|
+
|
|
673
|
+
it('helm spawnExec uses ctx?.timeout ?? DEFAULT_TIMEOUT', async () => {
|
|
674
|
+
const { readFileSync } = await import('node:fs');
|
|
675
|
+
const { join } = await import('node:path');
|
|
676
|
+
const devopsSrc = readFileSync(join(__dirname, '..', 'tools', 'schemas', 'devops.ts'), 'utf-8');
|
|
677
|
+
// There should be at least 2 occurrences of ctx?.timeout ?? DEFAULT_TIMEOUT (terraform + helm)
|
|
678
|
+
const occurrences = (devopsSrc.match(/ctx\?\.timeout \?\? DEFAULT_TIMEOUT/g) ?? []).length;
|
|
679
|
+
expect(occurrences).toBeGreaterThanOrEqual(2);
|
|
680
|
+
});
|
|
681
|
+
});
|
|
682
|
+
|
|
683
|
+
// ---------------------------------------------------------------------------
|
|
684
|
+
// GAP-20 Tests: ink/index.ts parseToolTimeouts integration
|
|
685
|
+
// ---------------------------------------------------------------------------
|
|
686
|
+
|
|
687
|
+
describe('GAP-20 — ink/index.ts parseToolTimeouts integration', () => {
|
|
688
|
+
it('parseToolTimeouts function is defined in ink/index.ts source', async () => {
|
|
689
|
+
const { readFileSync } = await import('node:fs');
|
|
690
|
+
const { join } = await import('node:path');
|
|
691
|
+
const inkSrc = readFileSync(join(__dirname, '..', 'ui', 'ink', 'index.ts'), 'utf-8');
|
|
692
|
+
expect(inkSrc).toContain('function parseToolTimeouts');
|
|
693
|
+
});
|
|
694
|
+
|
|
695
|
+
it('ink/index.ts passes toolTimeouts to runAgentLoop', async () => {
|
|
696
|
+
const { readFileSync } = await import('node:fs');
|
|
697
|
+
const { join } = await import('node:path');
|
|
698
|
+
const inkSrc = readFileSync(join(__dirname, '..', 'ui', 'ink', 'index.ts'), 'utf-8');
|
|
699
|
+
expect(inkSrc).toContain('toolTimeouts:');
|
|
700
|
+
expect(inkSrc).toContain('parseToolTimeouts(nimbusInstructions)');
|
|
701
|
+
});
|
|
702
|
+
|
|
703
|
+
it('parseToolTimeouts uses the ## Tool Timeouts section regex', async () => {
|
|
704
|
+
const { readFileSync } = await import('node:fs');
|
|
705
|
+
const { join } = await import('node:path');
|
|
706
|
+
const inkSrc = readFileSync(join(__dirname, '..', 'ui', 'ink', 'index.ts'), 'utf-8');
|
|
707
|
+
expect(inkSrc).toContain('Tool Timeouts');
|
|
708
|
+
expect(inkSrc).toContain('[a-z_]+');
|
|
709
|
+
});
|
|
710
|
+
});
|
|
711
|
+
|
|
712
|
+
// ---------------------------------------------------------------------------
|
|
713
|
+
// GAP-11 Tests: loop.ts FileDiff wiring source check
|
|
714
|
+
// ---------------------------------------------------------------------------
|
|
715
|
+
|
|
716
|
+
describe('GAP-11 — loop.ts FileDiff wiring', () => {
|
|
717
|
+
it('loop.ts contains GAP-11 comment for FileDiff trigger', async () => {
|
|
718
|
+
const { readFileSync } = await import('node:fs');
|
|
719
|
+
const { join } = await import('node:path');
|
|
720
|
+
const loopSrc = readFileSync(join(__dirname, '..', 'agent', 'loop.ts'), 'utf-8');
|
|
721
|
+
expect(loopSrc).toContain('GAP-11');
|
|
722
|
+
});
|
|
723
|
+
|
|
724
|
+
it('loop.ts imports parseTerraformPlanOutput from deploy-preview', async () => {
|
|
725
|
+
const { readFileSync } = await import('node:fs');
|
|
726
|
+
const { join } = await import('node:path');
|
|
727
|
+
const loopSrc = readFileSync(join(__dirname, '..', 'agent', 'loop.ts'), 'utf-8');
|
|
728
|
+
expect(loopSrc).toContain('parseTerraformPlanOutput');
|
|
729
|
+
expect(loopSrc).toContain('buildFileDiffBatchFromPlan');
|
|
730
|
+
expect(loopSrc).toContain('./deploy-preview');
|
|
731
|
+
});
|
|
732
|
+
|
|
733
|
+
it('loop.ts checks for terraform plan action before calling FileDiff', async () => {
|
|
734
|
+
const { readFileSync } = await import('node:fs');
|
|
735
|
+
const { join } = await import('node:path');
|
|
736
|
+
const loopSrc = readFileSync(join(__dirname, '..', 'agent', 'loop.ts'), 'utf-8');
|
|
737
|
+
expect(loopSrc).toContain("action === 'plan'");
|
|
738
|
+
expect(loopSrc).toContain('options.requestFileDiff');
|
|
739
|
+
});
|
|
740
|
+
|
|
741
|
+
it('loop.ts breaks on reject-all decision in FileDiff loop', async () => {
|
|
742
|
+
const { readFileSync } = await import('node:fs');
|
|
743
|
+
const { join } = await import('node:path');
|
|
744
|
+
const loopSrc = readFileSync(join(__dirname, '..', 'agent', 'loop.ts'), 'utf-8');
|
|
745
|
+
// The GAP-11 block should break on reject-all
|
|
746
|
+
expect(loopSrc).toContain("decision === 'reject-all'");
|
|
747
|
+
expect(loopSrc).toContain('break');
|
|
748
|
+
});
|
|
749
|
+
});
|
|
750
|
+
|
|
751
|
+
// ---------------------------------------------------------------------------
|
|
752
|
+
// GAP-18 Tests: loop.ts IaC validation wiring source check
|
|
753
|
+
// ---------------------------------------------------------------------------
|
|
754
|
+
|
|
755
|
+
describe('GAP-18 — loop.ts IaC validation wiring', () => {
|
|
756
|
+
it('loop.ts contains GAP-18 comment for terraform validate', async () => {
|
|
757
|
+
const { readFileSync } = await import('node:fs');
|
|
758
|
+
const { join } = await import('node:path');
|
|
759
|
+
const loopSrc = readFileSync(join(__dirname, '..', 'agent', 'loop.ts'), 'utf-8');
|
|
760
|
+
expect(loopSrc).toContain('GAP-18');
|
|
761
|
+
});
|
|
762
|
+
|
|
763
|
+
it('loop.ts checks for .tf file extension', async () => {
|
|
764
|
+
const { readFileSync } = await import('node:fs');
|
|
765
|
+
const { join } = await import('node:path');
|
|
766
|
+
const loopSrc = readFileSync(join(__dirname, '..', 'agent', 'loop.ts'), 'utf-8');
|
|
767
|
+
expect(loopSrc).toContain(".endsWith('.tf')");
|
|
768
|
+
});
|
|
769
|
+
|
|
770
|
+
it('loop.ts runs terraform validate -json', async () => {
|
|
771
|
+
const { readFileSync } = await import('node:fs');
|
|
772
|
+
const { join } = await import('node:path');
|
|
773
|
+
const loopSrc = readFileSync(join(__dirname, '..', 'agent', 'loop.ts'), 'utf-8');
|
|
774
|
+
expect(loopSrc).toContain('terraform validate -json');
|
|
775
|
+
});
|
|
776
|
+
|
|
777
|
+
it('loop.ts checks for write_file, edit_file, multi_edit tool names', async () => {
|
|
778
|
+
const { readFileSync } = await import('node:fs');
|
|
779
|
+
const { join } = await import('node:path');
|
|
780
|
+
const loopSrc = readFileSync(join(__dirname, '..', 'agent', 'loop.ts'), 'utf-8');
|
|
781
|
+
expect(loopSrc).toContain('write_file');
|
|
782
|
+
expect(loopSrc).toContain('edit_file');
|
|
783
|
+
expect(loopSrc).toContain('multi_edit');
|
|
784
|
+
});
|
|
785
|
+
|
|
786
|
+
it('loop.ts appends validation errors to toolContent', async () => {
|
|
787
|
+
const { readFileSync } = await import('node:fs');
|
|
788
|
+
const { join } = await import('node:path');
|
|
789
|
+
const loopSrc = readFileSync(join(__dirname, '..', 'agent', 'loop.ts'), 'utf-8');
|
|
790
|
+
expect(loopSrc).toContain('Terraform validation errors (please fix)');
|
|
791
|
+
});
|
|
792
|
+
|
|
793
|
+
it('loop.ts uses 10 second timeout for validate command', async () => {
|
|
794
|
+
const { readFileSync } = await import('node:fs');
|
|
795
|
+
const { join } = await import('node:path');
|
|
796
|
+
const loopSrc = readFileSync(join(__dirname, '..', 'agent', 'loop.ts'), 'utf-8');
|
|
797
|
+
expect(loopSrc).toContain('timeout: 10_000');
|
|
798
|
+
});
|
|
799
|
+
});
|
|
800
|
+
|
|
801
|
+
// ---------------------------------------------------------------------------
|
|
802
|
+
// Integration: parseToolTimeouts with a realistic NIMBUS.md
|
|
803
|
+
// ---------------------------------------------------------------------------
|
|
804
|
+
|
|
805
|
+
describe('GAP-20 — parseToolTimeouts with realistic NIMBUS.md', () => {
|
|
806
|
+
const realisticNimbusMd = `# NIMBUS.md
|
|
807
|
+
|
|
808
|
+
## Project
|
|
809
|
+
This is an AWS infrastructure project using Terraform and Kubernetes.
|
|
810
|
+
|
|
811
|
+
## Cloud Context
|
|
812
|
+
- AWS Account: 123456789012
|
|
813
|
+
- Region: us-east-1
|
|
814
|
+
|
|
815
|
+
## Tool Timeouts
|
|
816
|
+
terraform: 900000
|
|
817
|
+
kubectl: 180000
|
|
818
|
+
helm: 600000
|
|
819
|
+
cloud_discover: 30000
|
|
820
|
+
|
|
821
|
+
## Instructions
|
|
822
|
+
Always run terraform plan before apply.
|
|
823
|
+
`;
|
|
824
|
+
|
|
825
|
+
it('parses all tool timeouts from a realistic NIMBUS.md', () => {
|
|
826
|
+
const result = parseToolTimeouts(realisticNimbusMd);
|
|
827
|
+
expect(result.terraform).toBe(900000);
|
|
828
|
+
expect(result.kubectl).toBe(180000);
|
|
829
|
+
expect(result.helm).toBe(600000);
|
|
830
|
+
expect(result.cloud_discover).toBe(30000);
|
|
831
|
+
});
|
|
832
|
+
|
|
833
|
+
it('does not include keys from other sections', () => {
|
|
834
|
+
const result = parseToolTimeouts(realisticNimbusMd);
|
|
835
|
+
expect(Object.keys(result)).toHaveLength(4);
|
|
836
|
+
});
|
|
837
|
+
|
|
838
|
+
it('returns correct types (numbers not strings)', () => {
|
|
839
|
+
const result = parseToolTimeouts(realisticNimbusMd);
|
|
840
|
+
for (const value of Object.values(result)) {
|
|
841
|
+
expect(typeof value).toBe('number');
|
|
842
|
+
}
|
|
843
|
+
});
|
|
844
|
+
|
|
845
|
+
it('handles NIMBUS.md with no Tool Timeouts section gracefully', () => {
|
|
846
|
+
const minimal = `# NIMBUS.md\n\n## Project\nSome project.\n`;
|
|
847
|
+
const result = parseToolTimeouts(minimal);
|
|
848
|
+
expect(result).toEqual({});
|
|
849
|
+
});
|
|
850
|
+
|
|
851
|
+
it('handles NIMBUS.md where Tool Timeouts is the last section', () => {
|
|
852
|
+
const lastSection = `# NIMBUS.md\n\n## Project\nSome project.\n\n## Tool Timeouts\nbash: 60000\n`;
|
|
853
|
+
const result = parseToolTimeouts(lastSection);
|
|
854
|
+
expect(result.bash).toBe(60000);
|
|
855
|
+
});
|
|
856
|
+
});
|
|
857
|
+
|
|
858
|
+
// ---------------------------------------------------------------------------
|
|
859
|
+
// H2: Parallel read-only tool dispatch
|
|
860
|
+
// ---------------------------------------------------------------------------
|
|
861
|
+
|
|
862
|
+
describe('parallel read-only tool dispatch (H2)', () => {
|
|
863
|
+
it('loop.ts contains READ_ONLY_TOOLS set', async () => {
|
|
864
|
+
const { readFileSync } = await import('node:fs');
|
|
865
|
+
const { join } = await import('node:path');
|
|
866
|
+
const src = readFileSync(join(process.cwd(), 'src/agent/loop.ts'), 'utf-8');
|
|
867
|
+
expect(src).toContain('READ_ONLY_TOOLS');
|
|
868
|
+
});
|
|
869
|
+
|
|
870
|
+
it('READ_ONLY_TOOLS includes cloud_discover and read_file', async () => {
|
|
871
|
+
const { readFileSync } = await import('node:fs');
|
|
872
|
+
const { join } = await import('node:path');
|
|
873
|
+
const src = readFileSync(join(process.cwd(), 'src/agent/loop.ts'), 'utf-8');
|
|
874
|
+
expect(src).toContain("'cloud_discover'");
|
|
875
|
+
expect(src).toContain("'read_file'");
|
|
876
|
+
});
|
|
877
|
+
|
|
878
|
+
it('parallel dispatch uses Promise.allSettled', async () => {
|
|
879
|
+
const { readFileSync } = await import('node:fs');
|
|
880
|
+
const { join } = await import('node:path');
|
|
881
|
+
const src = readFileSync(join(process.cwd(), 'src/agent/loop.ts'), 'utf-8');
|
|
882
|
+
expect(src).toContain('Promise.allSettled');
|
|
883
|
+
});
|
|
884
|
+
|
|
885
|
+
it('allReadOnly check requires length > 1', async () => {
|
|
886
|
+
const { readFileSync } = await import('node:fs');
|
|
887
|
+
const { join } = await import('node:path');
|
|
888
|
+
const src = readFileSync(join(process.cwd(), 'src/agent/loop.ts'), 'utf-8');
|
|
889
|
+
expect(src).toContain('allReadOnly && responseToolCalls.length > 1');
|
|
890
|
+
});
|
|
891
|
+
|
|
892
|
+
it('cloudDiscoverTool schema has regions array field', async () => {
|
|
893
|
+
const { devopsTools } = await import('../tools/schemas/devops');
|
|
894
|
+
const tool = devopsTools.find(t => t.name === 'cloud_discover');
|
|
895
|
+
expect(tool).toBeDefined();
|
|
896
|
+
// Schema should have regions field
|
|
897
|
+
const { readFileSync } = await import('node:fs');
|
|
898
|
+
const { join } = await import('node:path');
|
|
899
|
+
const src = readFileSync(join(process.cwd(), 'src/tools/schemas/devops.ts'), 'utf-8');
|
|
900
|
+
expect(src).toContain('regions: z.array(z.string())');
|
|
901
|
+
});
|
|
902
|
+
|
|
903
|
+
it('parallel dispatch uses continue to skip sequential loop', async () => {
|
|
904
|
+
const { readFileSync } = await import('node:fs');
|
|
905
|
+
const { join } = await import('node:path');
|
|
906
|
+
const src = readFileSync(join(process.cwd(), 'src/agent/loop.ts'), 'utf-8');
|
|
907
|
+
expect(src).toContain('Skip sequential processing');
|
|
908
|
+
});
|
|
909
|
+
|
|
910
|
+
it('READ_ONLY_TOOLS includes kubectl_context', async () => {
|
|
911
|
+
const { readFileSync } = await import('node:fs');
|
|
912
|
+
const { join } = await import('node:path');
|
|
913
|
+
const src = readFileSync(join(process.cwd(), 'src/agent/loop.ts'), 'utf-8');
|
|
914
|
+
expect(src).toContain("'kubectl_context'");
|
|
915
|
+
});
|
|
916
|
+
|
|
917
|
+
it('READ_ONLY_TOOLS includes helm_values', async () => {
|
|
918
|
+
const { readFileSync } = await import('node:fs');
|
|
919
|
+
const { join } = await import('node:path');
|
|
920
|
+
const src = readFileSync(join(process.cwd(), 'src/agent/loop.ts'), 'utf-8');
|
|
921
|
+
expect(src).toContain("'helm_values'");
|
|
922
|
+
});
|
|
923
|
+
});
|
|
924
|
+
|
|
925
|
+
// ---------------------------------------------------------------------------
|
|
926
|
+
// H1: LIVE streaming indicator
|
|
927
|
+
// ---------------------------------------------------------------------------
|
|
928
|
+
|
|
929
|
+
describe('LIVE streaming indicator (H1)', () => {
|
|
930
|
+
it('ToolCallDisplay has LIVE indicator for logs tool', async () => {
|
|
931
|
+
const { readFileSync } = await import('node:fs');
|
|
932
|
+
const { join } = await import('node:path');
|
|
933
|
+
const src = readFileSync(join(process.cwd(), 'src/ui/ToolCallDisplay.tsx'), 'utf-8');
|
|
934
|
+
expect(src).toContain('● LIVE');
|
|
935
|
+
});
|
|
936
|
+
|
|
937
|
+
it('StatusBar has showStreamingHint prop', async () => {
|
|
938
|
+
const { readFileSync } = await import('node:fs');
|
|
939
|
+
const { join } = await import('node:path');
|
|
940
|
+
const src = readFileSync(join(process.cwd(), 'src/ui/StatusBar.tsx'), 'utf-8');
|
|
941
|
+
expect(src).toContain('showStreamingHint');
|
|
942
|
+
});
|
|
943
|
+
|
|
944
|
+
it('StatusBar shows Esc:stop stream when streaming hint active', async () => {
|
|
945
|
+
const { readFileSync } = await import('node:fs');
|
|
946
|
+
const { join } = await import('node:path');
|
|
947
|
+
const src = readFileSync(join(process.cwd(), 'src/ui/StatusBar.tsx'), 'utf-8');
|
|
948
|
+
expect(src).toContain('Esc:stop stream');
|
|
949
|
+
});
|
|
950
|
+
|
|
951
|
+
it('ToolCallDisplay streaming window is 40 for generic tools (M1: increased from 20)', async () => {
|
|
952
|
+
const { readFileSync } = await import('node:fs');
|
|
953
|
+
const { join } = await import('node:path');
|
|
954
|
+
const src = readFileSync(join(process.cwd(), 'src/ui/ToolCallDisplay.tsx'), 'utf-8');
|
|
955
|
+
// M1: Streaming window was increased — 60 lines for terraform/kubectl/logs, 40 for other tools
|
|
956
|
+
expect(src).toContain('windowSize = isTerraformOrKubectl ? 60 : 40');
|
|
957
|
+
});
|
|
958
|
+
});
|