machinaos 0.0.76 → 0.0.78
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/README.md +143 -107
- package/client/dist/assets/ActionBar-Du2MSFSz.js +1 -0
- package/client/dist/assets/ApiKeyInput-k2LBmBjb.js +1 -0
- package/client/dist/assets/ApiKeyPanel-C_bV9U0X.js +1 -0
- package/client/dist/assets/ApiUsageSection-CmVfwZzL.js +1 -0
- package/client/dist/assets/EmailPanel-CeKIMGu-.js +1 -0
- package/client/dist/assets/OAuthPanel-KA3t3Q2K.js +1 -0
- package/client/dist/assets/QrPairingPanel-NgNpJNuk.js +1 -0
- package/client/dist/assets/RateLimitSection-Du5YNVIA.js +1 -0
- package/client/dist/assets/StatusCard-DNLyayXc.js +1 -0
- package/client/dist/assets/index-DQ0nwhec.js +257 -0
- package/client/dist/assets/index-DxmbVskS.css +1 -0
- package/client/dist/assets/vendor-flow-CZmBvHRo.js +1 -0
- package/client/dist/assets/vendor-icons-CVrPjN2Q.js +22 -0
- package/client/dist/assets/vendor-markdown-CRou3yQ5.js +62 -0
- package/client/dist/assets/vendor-misc-C4VxKHs5.js +1 -0
- package/client/dist/assets/vendor-query-SzWcOU0G.js +1 -0
- package/client/dist/assets/vendor-radix-Dnos29jG.js +56 -0
- package/client/dist/assets/vendor-react-DvWIbVx0.js +1 -0
- package/client/dist/index.html +37 -3
- package/client/index.html +28 -1
- package/client/package.json +44 -40
- package/client/src/App.tsx +2 -0
- package/client/src/Dashboard.tsx +157 -45
- package/client/src/ParameterPanel.tsx +3 -5
- package/client/src/adapters/nodeSpecToDescription.ts +1 -0
- package/client/src/assets/icons/NodeIcon.tsx +32 -0
- package/client/src/assets/icons/index.ts +4 -0
- package/client/src/assets/icons/stripe.svg +1 -0
- package/client/src/assets/icons/themedGlyphs.ts +404 -0
- package/client/src/components/AIAgentNode.tsx +77 -53
- package/client/src/components/GenericNode.tsx +34 -52
- package/client/src/components/OutputPanel.tsx +64 -147
- package/client/src/components/ParameterRenderer.tsx +5 -3
- package/client/src/components/SkillEditorModal.tsx +9 -18
- package/client/src/components/SquareNode.tsx +97 -115
- package/client/src/components/StartNode.tsx +32 -42
- package/client/src/components/SvgFilterDefs.tsx +54 -0
- package/client/src/components/TeamMonitorNode.tsx +12 -14
- package/client/src/components/ToolkitNode.tsx +35 -60
- package/client/src/components/TriggerNode.tsx +43 -77
- package/client/src/components/__tests__/CredentialsModal.test.tsx +49 -45
- package/client/src/components/credentials/CredentialsModal.tsx +98 -30
- package/client/src/components/credentials/CredentialsPalette.tsx +73 -5
- package/client/src/components/credentials/catalogueAdapter.ts +17 -1
- package/client/src/components/credentials/panels/ApiKeyPanel.tsx +102 -37
- package/client/src/components/credentials/panels/EmailPanel.tsx +7 -19
- package/client/src/components/credentials/panels/OAuthPanel.tsx +5 -1
- package/client/src/components/credentials/panels/QrPairingPanel.tsx +1 -3
- package/client/src/components/credentials/primitives/ActionBar.tsx +7 -11
- package/client/src/components/credentials/primitives/OAuthConnect.tsx +19 -28
- package/client/src/components/credentials/sections/ProviderDefaultsSection.tsx +24 -3
- package/client/src/components/credentials/types.ts +12 -2
- package/client/src/components/credentials/useCredentialPanel.ts +43 -19
- package/client/src/components/icons/AIProviderIcons.tsx +16 -0
- package/client/src/components/onboarding/OnboardingWizard.tsx +23 -63
- package/client/src/components/onboarding/nodeRoleClasses.ts +23 -0
- package/client/src/components/onboarding/steps/CanvasStep.tsx +15 -21
- package/client/src/components/onboarding/steps/ConceptsStep.tsx +2 -11
- package/client/src/components/onboarding/steps/GetStartedStep.tsx +2 -10
- package/client/src/components/parameterPanel/InputSection.tsx +9 -7
- package/client/src/components/parameterPanel/MasterSkillEditor.tsx +84 -198
- package/client/src/components/parameterPanel/MiddleSection.tsx +57 -80
- package/client/src/components/parameterPanel/ToolSchemaEditor.tsx +31 -25
- package/client/src/components/parameterPanel/__tests__/InputSection.test.tsx +7 -2
- package/client/src/components/ui/AIResultModal.tsx +1 -1
- package/client/src/components/ui/CollapsibleSection.tsx +9 -5
- package/client/src/components/ui/CommandPalette.tsx +147 -0
- package/client/src/components/ui/CommandPaletteHost.tsx +189 -0
- package/client/src/components/ui/ComponentItem.tsx +13 -7
- package/client/src/components/ui/ComponentPalette.tsx +24 -13
- package/client/src/components/ui/ConsolePanel.tsx +19 -11
- package/client/src/components/ui/DropCap.tsx +28 -0
- package/client/src/components/ui/EditableNodeLabel.tsx +10 -2
- package/client/src/components/ui/InputNodesPanel.tsx +1 -1
- package/client/src/components/ui/Modal.tsx +38 -6
- package/client/src/components/ui/OutputDisplayPanel.tsx +1 -1
- package/client/src/components/ui/SettingsPanel.tsx +42 -13
- package/client/src/components/ui/StatusBar.tsx +108 -0
- package/client/src/components/ui/ThemeSwitcher.tsx +109 -0
- package/client/src/components/ui/TopToolbar.tsx +42 -25
- package/client/src/components/ui/WorkflowSidebar.tsx +32 -16
- package/client/src/components/ui/action-button.tsx +40 -15
- package/client/src/components/ui/button.tsx +24 -1
- package/client/src/components/ui/dropdown-menu.tsx +24 -2
- package/client/src/components/ui/input.tsx +19 -2
- package/client/src/components/ui/select.tsx +15 -0
- package/client/src/components/ui/textarea.tsx +15 -2
- package/client/src/contexts/AuthContext.tsx +148 -109
- package/client/src/contexts/ThemeContext.tsx +93 -17
- package/client/src/contexts/WebSocketContext.tsx +373 -206
- package/client/src/contexts/__tests__/AuthContext.test.tsx +221 -0
- package/client/src/hooks/__tests__/useDragVariable.test.ts +7 -1
- package/client/src/hooks/__tests__/useWorkflowOpsListener.test.ts +142 -0
- package/client/src/hooks/useAppTheme.ts +209 -7
- package/client/src/hooks/useAutoSkillEdges.ts +7 -2
- package/client/src/hooks/useCatalogueQuery.ts +67 -1
- package/client/src/hooks/useDragVariable.ts +1 -1
- package/client/src/hooks/useNodeAllowlist.ts +115 -8
- package/client/src/hooks/useOnboarding.ts +20 -8
- package/client/src/hooks/useParameterPanel.ts +2 -1
- package/client/src/hooks/useReactFlowNodes.ts +2 -1
- package/client/src/hooks/useSound.ts +185 -0
- package/client/src/hooks/useWorkflowManagement.ts +6 -8
- package/client/src/hooks/useWorkflowOpsListener.ts +90 -0
- package/client/src/index.css +65 -3
- package/client/src/lib/__tests__/connectionConfig.test.ts +91 -0
- package/client/src/lib/aiModelProviders.ts +8 -0
- package/client/src/lib/connectionConfig.ts +107 -0
- package/client/src/lib/queryPersist.ts +13 -5
- package/client/src/lib/sound.ts +393 -0
- package/client/src/main.tsx +20 -0
- package/client/src/store/useAppStore.ts +26 -0
- package/client/src/styles/canvasAnimations.ts +37 -36
- package/client/src/styles/theme.ts +36 -20
- package/client/src/test/setup.ts +1 -0
- package/client/src/themes/atomic.css +253 -0
- package/client/src/themes/base.css +373 -0
- package/client/src/themes/cyber.css +890 -0
- package/client/src/themes/dark.css +70 -0
- package/client/src/themes/edo.css +246 -0
- package/client/src/themes/greek.css +293 -0
- package/client/src/themes/light.css +78 -0
- package/client/src/themes/plague.css +253 -0
- package/client/src/themes/renaissance.css +727 -0
- package/client/src/themes/rot.css +249 -0
- package/client/src/themes/steampunk.css +272 -0
- package/client/src/themes/surveillance.css +289 -0
- package/client/src/themes/wasteland.css +250 -0
- package/client/src/types/INodeProperties.ts +5 -0
- package/client/src/types/NodeTypes.ts +11 -1
- package/client/src/types/__tests__/cloudEvents.test.ts +99 -0
- package/client/src/types/cloudEvents.ts +78 -0
- package/client/src/vite-env.d.ts +7 -0
- package/client/tsconfig.json +1 -1
- package/client/vite.config.js +62 -2
- package/install.ps1 +1 -1
- package/install.sh +1 -1
- package/machina/commands/build.py +51 -7
- package/machina/pyproject.toml +4 -0
- package/machina/supervisor.py +12 -2
- package/machina/tree.py +71 -21
- package/package.json +4 -4
- package/scripts/install.js +16 -1
- package/server/config/ai_cli_providers.json +54 -0
- package/server/config/credential_providers.json +109 -2
- package/server/config/llm_defaults.json +24 -0
- package/server/config/model_registry.json +338 -499
- package/server/config/node_allowlist.json +16 -1
- package/server/config/pricing.json +8 -0
- package/server/constants.py +38 -15
- package/server/core/container.py +2 -2
- package/server/core/credentials_database.py +35 -2
- package/server/core/logging.py +4 -3
- package/server/main.py +99 -13
- package/server/models/node_metadata.py +1 -0
- package/server/nodejs/package.json +8 -6
- package/server/nodejs/src/index.ts +22 -5
- package/server/nodes/README.md +31 -4
- package/server/nodes/agent/_inline.py +2 -0
- package/server/nodes/agent/_specialized.py +6 -3
- package/server/nodes/agent/ai_agent.py +13 -3
- package/server/nodes/agent/chat_agent.py +6 -3
- package/server/nodes/agent/claude_code_agent.py +287 -75
- package/server/nodes/agent/codex_agent.py +239 -0
- package/server/nodes/agent/deep_agent.py +3 -3
- package/server/nodes/agent/rlm_agent.py +3 -3
- package/server/nodes/android/__init__.py +31 -1
- package/server/nodes/android/_base.py +9 -5
- package/server/{services/android_service.py → nodes/android/_dispatcher.py} +2 -2
- package/server/nodes/android/_handlers.py +154 -0
- package/server/nodes/android/_option_loaders.py +44 -0
- package/server/nodes/android/_refresh.py +127 -0
- package/server/{services/android → nodes/android/_relay}/client.py +4 -4
- package/server/{routers/android.py → nodes/android/_router.py} +27 -8
- package/server/nodes/browser/browser.py +2 -2
- package/server/nodes/code/_base.py +6 -2
- package/server/nodes/code/_claude_code.py +134 -0
- package/server/nodes/document/embedding_generator.py +3 -3
- package/server/nodes/document/http_scraper.py +3 -3
- package/server/nodes/document/vector_store.py +5 -5
- package/server/nodes/email/__init__.py +11 -1
- package/server/nodes/email/_filters.py +21 -0
- package/server/{services/himalaya_service.py → nodes/email/_himalaya.py} +6 -10
- package/server/{services/email_service.py → nodes/email/_service.py} +9 -13
- package/server/nodes/email/email_read.py +1 -1
- package/server/nodes/email/email_receive.py +54 -5
- package/server/nodes/email/email_send.py +1 -1
- package/server/nodes/filesystem/shell.py +24 -1
- package/server/nodes/google/__init__.py +55 -1
- package/server/{services/handlers/google_auth.py → nodes/google/_auth_helper.py} +8 -5
- package/server/nodes/google/_base.py +2 -2
- package/server/nodes/google/_credentials.py +5 -5
- package/server/nodes/google/_filters.py +25 -0
- package/server/nodes/google/_handlers.py +57 -0
- package/server/{services/google_oauth.py → nodes/google/_oauth.py} +195 -162
- package/server/nodes/google/_option_loaders.py +107 -0
- package/server/nodes/google/_refresh.py +66 -0
- package/server/nodes/google/_router.py +131 -0
- package/server/nodes/google/gmail_receive.py +41 -4
- package/server/nodes/groups.py +1 -0
- package/server/nodes/location/_credentials.py +45 -1
- package/server/{services/maps.py → nodes/location/_service.py} +18 -3
- package/server/nodes/location/gmaps_create.py +4 -4
- package/server/nodes/location/gmaps_locations.py +4 -4
- package/server/nodes/location/gmaps_nearby_places.py +4 -4
- package/server/nodes/model/_base.py +8 -3
- package/server/nodes/model/_credentials.py +96 -8
- package/server/nodes/model/_local_validator.py +345 -0
- package/server/nodes/model/lmstudio_chat_model.py +23 -0
- package/server/nodes/model/ollama_chat_model.py +25 -0
- package/server/nodes/proxy/_usage.py +2 -2
- package/server/nodes/proxy/proxy_config.py +14 -14
- package/server/nodes/proxy/proxy_request.py +4 -4
- package/server/nodes/scraper/_credentials.py +29 -1
- package/server/nodes/scraper/apify_actor.py +9 -9
- package/server/nodes/scraper/crawlee_scraper.py +5 -5
- package/server/nodes/search/brave_search.py +4 -0
- package/server/nodes/search/perplexity_search.py +9 -0
- package/server/nodes/search/serper_search.py +3 -0
- package/server/nodes/skill/simple_memory.py +12 -0
- package/server/nodes/social/_base.py +2 -2
- package/server/nodes/stripe/__init__.py +46 -0
- package/server/nodes/stripe/_credentials.py +33 -0
- package/server/nodes/stripe/_handlers.py +270 -0
- package/server/nodes/stripe/_install.py +127 -0
- package/server/nodes/stripe/_source.py +174 -0
- package/server/nodes/stripe/stripe_action.py +81 -0
- package/server/nodes/stripe/stripe_receive.py +92 -0
- package/server/nodes/telegram/_credentials.py +52 -1
- package/server/nodes/telegram/_handlers.py +19 -18
- package/server/nodes/telegram/_service.py +134 -32
- package/server/nodes/telegram/telegram_send.py +5 -6
- package/server/nodes/text/file_handler.py +2 -2
- package/server/nodes/text/text_generator.py +2 -2
- package/server/nodes/tool/agent_builder.py +630 -0
- package/server/nodes/tool/task_manager.py +144 -2
- package/server/nodes/twitter/__init__.py +38 -1
- package/server/nodes/twitter/_base.py +7 -7
- package/server/nodes/twitter/_credentials.py +1 -1
- package/server/nodes/twitter/_filters.py +37 -0
- package/server/nodes/twitter/_handlers.py +77 -0
- package/server/nodes/twitter/_oauth.py +124 -0
- package/server/nodes/twitter/_refresh.py +78 -0
- package/server/nodes/twitter/_router.py +29 -0
- package/server/nodes/twitter/twitter_receive.py +4 -0
- package/server/nodes/visuals.json +64 -19
- package/server/nodes/whatsapp/__init__.py +45 -5
- package/server/nodes/whatsapp/_base.py +3 -3
- package/server/nodes/whatsapp/_filters.py +137 -0
- package/server/nodes/whatsapp/_handlers.py +167 -0
- package/server/nodes/whatsapp/_option_loaders.py +68 -0
- package/server/nodes/whatsapp/_refresh.py +62 -0
- package/server/nodes/whatsapp/_runtime.py +1 -1
- package/server/pyproject.toml +29 -7
- package/server/routers/schemas.py +2 -2
- package/server/routers/webhook.py +26 -9
- package/server/routers/websocket.py +149 -810
- package/server/services/ai.py +89 -8
- package/server/services/auth.py +220 -43
- package/server/services/claude_oauth.py +126 -100
- package/server/services/cli_agent/__init__.py +78 -0
- package/server/services/cli_agent/_handlers.py +237 -0
- package/server/services/cli_agent/config.py +112 -0
- package/server/services/cli_agent/factory.py +48 -0
- package/server/services/cli_agent/lockfile.py +141 -0
- package/server/services/cli_agent/mcp_server.py +482 -0
- package/server/services/cli_agent/protocol.py +173 -0
- package/server/services/cli_agent/providers/__init__.py +9 -0
- package/server/services/cli_agent/providers/anthropic_claude.py +419 -0
- package/server/services/cli_agent/providers/google_gemini.py +80 -0
- package/server/services/cli_agent/providers/openai_codex.py +310 -0
- package/server/services/cli_agent/service.py +607 -0
- package/server/services/cli_agent/session.py +618 -0
- package/server/services/cli_agent/types.py +227 -0
- package/server/services/cli_agent/workflow_tools.py +233 -0
- package/server/services/credential_registry.py +26 -1
- package/server/services/deployment/manager.py +26 -145
- package/server/services/deployment/poll_registry.py +59 -0
- package/server/services/event_waiter.py +76 -246
- package/server/services/events/__init__.py +54 -0
- package/server/services/events/cli.py +78 -0
- package/server/services/events/daemon.py +163 -0
- package/server/services/events/envelope.py +281 -0
- package/server/services/events/lifecycle.py +99 -0
- package/server/services/events/oauth_lifecycle.py +534 -0
- package/server/services/events/polling.py +60 -0
- package/server/services/events/push.py +36 -0
- package/server/services/events/source.py +63 -0
- package/server/services/events/triggers.py +118 -0
- package/server/services/events/verifiers/__init__.py +25 -0
- package/server/services/events/verifiers/base.py +28 -0
- package/server/services/events/verifiers/github.py +25 -0
- package/server/services/events/verifiers/hmac_basic.py +32 -0
- package/server/services/events/verifiers/standard_webhooks.py +47 -0
- package/server/services/events/verifiers/stripe.py +42 -0
- package/server/services/events/webhook.py +105 -0
- package/server/services/handlers/tools.py +28 -186
- package/server/services/llm/config.py +7 -0
- package/server/services/llm/factory.py +8 -2
- package/server/services/memory/__init__.py +52 -0
- package/server/services/memory/jsonl.py +80 -0
- package/server/services/memory/markdown.py +65 -0
- package/server/services/memory/state.py +112 -0
- package/server/services/memory/vector_store.py +40 -0
- package/server/services/model_registry.py +76 -0
- package/server/services/node_allowlist.py +71 -15
- package/server/services/node_executor.py +2 -2
- package/server/services/node_output_schemas.py +21 -10
- package/server/services/node_spec.py +1 -1
- package/server/services/oauth_utils.py +1 -1
- package/server/services/plugin/__init__.py +2 -0
- package/server/services/plugin/base.py +44 -2
- package/server/services/plugin/credential.py +288 -1
- package/server/services/plugin/deps.py +105 -0
- package/server/services/plugin/edge_walker.py +12 -4
- package/server/services/plugin/oauth.py +381 -0
- package/server/services/plugin/polling.py +247 -0
- package/server/services/plugin/registry.py +145 -0
- package/server/services/plugin/singleton.py +65 -0
- package/server/services/plugin/ws.py +81 -0
- package/server/services/process_service.py +31 -2
- package/server/services/status_broadcaster.py +155 -238
- package/server/services/temporal/workflow.py +7 -7
- package/server/services/workflow.py +21 -3
- package/server/services/ws_handler_registry.py +111 -28
- package/server/skills/GUIDE.md +16 -1
- package/server/skills/assistant/agent-builder-skill/SKILL.md +166 -0
- package/server/skills/payments_agent/stripe-skill/SKILL.md +306 -0
- package/server/tests/credentials/test_auth_service.py +16 -9
- package/server/tests/credentials/test_credential_broadcasts.py +219 -0
- package/server/tests/credentials/test_google_oauth.py +6 -6
- package/server/tests/credentials/test_oauth_utils.py +1 -1
- package/server/tests/credentials/test_twitter_oauth.py +2 -2
- package/server/tests/credentials/test_websocket_handlers.py +44 -20
- package/server/tests/llm/test_factory.py +1 -0
- package/server/tests/llm/test_wiring.py +5 -1
- package/server/tests/nodes/_compat.py +24 -24
- package/server/tests/nodes/test_agent_builder.py +439 -0
- package/server/tests/nodes/test_ai_tools.py +18 -14
- package/server/tests/nodes/test_code_fs_process.py +17 -8
- package/server/tests/nodes/test_email.py +10 -9
- package/server/tests/nodes/test_google_workspace.py +2 -2
- package/server/tests/nodes/test_specialized_agents.py +100 -53
- package/server/tests/nodes/test_stripe_plugin.py +293 -0
- package/server/tests/nodes/test_telegram_social.py +4 -4
- package/server/tests/nodes/test_twitter.py +1 -1
- package/server/tests/nodes/test_web_automation.py +2 -2
- package/server/tests/nodes/test_whatsapp.py +9 -9
- package/server/tests/services/cli_agent/__init__.py +0 -0
- package/server/tests/services/cli_agent/test_mcp_server.py +432 -0
- package/server/tests/services/cli_agent/test_providers.py +358 -0
- package/server/tests/services/cli_agent/test_service.py +298 -0
- package/server/tests/services/memory/__init__.py +0 -0
- package/server/tests/services/memory/test_jsonl.py +188 -0
- package/server/tests/services/test_events.py +333 -0
- package/server/tests/test_node_spec.py +56 -16
- package/server/tests/test_plugin_helpers.py +116 -0
- package/server/tests/test_plugin_self_containment.py +486 -0
- package/server/tests/test_status_broadcasts.py +425 -0
- package/workflows/{AI Assistant_workflow-1777421105154-0m4snkzjf.json → AI Assistant_workflow-1778504793388-ou1m1tz2x.json } +70 -266
- package/workflows/{AI Employee_workflow-1777720598005-u4cm858dv.json → AI Employee_example_workflow-1777720598005-u4cm858dv.json } +112 -112
- package/workflows/Claude Assistant_workflow-1778380124051-mdibn807c.json +709 -0
- package/client/dist/assets/ActionBar-vzPpSR77.js +0 -1
- package/client/dist/assets/ApiKeyInput-Ds7AKFe8.js +0 -1
- package/client/dist/assets/ApiKeyPanel-gfblELep.js +0 -1
- package/client/dist/assets/ApiUsageSection-BMNWTe2r.js +0 -1
- package/client/dist/assets/EmailPanel-B1Om64p5.js +0 -1
- package/client/dist/assets/OAuthPanel-CXyQYGBz.js +0 -1
- package/client/dist/assets/QrPairingPanel-BgNuI1we.js +0 -1
- package/client/dist/assets/RateLimitSection-YYK8sx1T.js +0 -1
- package/client/dist/assets/StatusCard-DuYA5hJR.js +0 -1
- package/client/dist/assets/index-D9tZfgvi.js +0 -363
- package/client/dist/assets/index-al7snTkG.css +0 -1
- package/client/src/components/credentials/providers.tsx +0 -177
- package/server/routers/google.py +0 -277
- package/server/routers/maps.py +0 -142
- package/server/routers/twitter.py +0 -365
- package/server/services/claude_code_service.py +0 -106
- package/server/services/memory.py +0 -159
- package/server/services/node_option_loaders/__init__.py +0 -77
- package/server/services/node_option_loaders/android_loaders.py +0 -55
- package/server/services/node_option_loaders/google_loaders.py +0 -97
- package/server/services/node_option_loaders/whatsapp_loaders.py +0 -69
- package/server/services/twitter_oauth.py +0 -411
- package/server/services/websocket_client.py +0 -29
- /package/server/{services/android → nodes/android/_relay}/__init__.py +0 -0
- /package/server/{services/android → nodes/android/_relay}/broadcaster.py +0 -0
- /package/server/{services/android → nodes/android/_relay}/manager.py +0 -0
- /package/server/{services/android → nodes/android/_relay}/protocol.py +0 -0
- /package/server/{services/browser_service.py → nodes/browser/_service.py} +0 -0
- /package/server/{services/whatsapp_service.py → nodes/whatsapp/_service.py} +0 -0
- /package/server/skills/{task_agent → assistant}/write-todos-skill/SKILL.md +0 -0
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
"""Unit tests for `AnthropicClaudeProvider` and `OpenAICodexProvider`.
|
|
2
|
+
|
|
3
|
+
Covers:
|
|
4
|
+
- `headless_argv` shape (every flag in the right place, defaults
|
|
5
|
+
applied, optional fields omitted when unset)
|
|
6
|
+
- `parse_event` round-trips JSON correctly, returns None for garbage
|
|
7
|
+
- `is_final_event` matches the right event types
|
|
8
|
+
- `event_to_session_result` reconstructs cost / session_id /
|
|
9
|
+
canonical_usage / response from vendored NDJSON
|
|
10
|
+
- `detect_auth_error` matches "not logged in" stderr patterns
|
|
11
|
+
- `supports()` flags align with `ai_cli_providers.json`
|
|
12
|
+
- Factory raises NotImplementedError for gemini
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
import pytest
|
|
21
|
+
|
|
22
|
+
from services.cli_agent import (
|
|
23
|
+
ClaudeTaskSpec,
|
|
24
|
+
CodexTaskSpec,
|
|
25
|
+
create_cli_provider,
|
|
26
|
+
)
|
|
27
|
+
from services.cli_agent.factory import is_supported
|
|
28
|
+
from services.cli_agent.protocol import AICliProvider, CanonicalUsage
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# ---------------------------------------------------------------------------
|
|
32
|
+
# Factory contract
|
|
33
|
+
# ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
class TestFactory:
|
|
36
|
+
def test_claude_creates_provider(self):
|
|
37
|
+
p = create_cli_provider("claude")
|
|
38
|
+
assert p.name == "claude"
|
|
39
|
+
assert isinstance(p, AICliProvider)
|
|
40
|
+
|
|
41
|
+
def test_codex_creates_provider(self):
|
|
42
|
+
p = create_cli_provider("codex")
|
|
43
|
+
assert p.name == "codex"
|
|
44
|
+
assert isinstance(p, AICliProvider)
|
|
45
|
+
|
|
46
|
+
def test_gemini_raises_not_implemented(self):
|
|
47
|
+
with pytest.raises(NotImplementedError, match="deferred to v2"):
|
|
48
|
+
create_cli_provider("gemini")
|
|
49
|
+
|
|
50
|
+
def test_unknown_raises_value_error(self):
|
|
51
|
+
with pytest.raises(ValueError, match="Unknown CLI provider"):
|
|
52
|
+
create_cli_provider("openai")
|
|
53
|
+
|
|
54
|
+
def test_is_supported(self):
|
|
55
|
+
assert is_supported("claude") is True
|
|
56
|
+
assert is_supported("codex") is True
|
|
57
|
+
assert is_supported("gemini") is False
|
|
58
|
+
assert is_supported("nope") is False
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# ---------------------------------------------------------------------------
|
|
62
|
+
# Claude provider
|
|
63
|
+
# ---------------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
@pytest.fixture
|
|
66
|
+
def claude_provider():
|
|
67
|
+
return create_cli_provider("claude")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class TestClaudeArgv:
|
|
71
|
+
def test_minimum_required_flags(self, claude_provider):
|
|
72
|
+
task = ClaudeTaskSpec(prompt="hello world")
|
|
73
|
+
argv = claude_provider.headless_argv(task, defaults={})
|
|
74
|
+
# `-p hello world` must be present
|
|
75
|
+
assert "-p" in argv
|
|
76
|
+
assert "hello world" in argv
|
|
77
|
+
# stream-json output format
|
|
78
|
+
assert "--output-format" in argv
|
|
79
|
+
assert "stream-json" in argv
|
|
80
|
+
# Defaults applied
|
|
81
|
+
assert "--model" in argv
|
|
82
|
+
assert "claude-sonnet-4-6" in argv # default_model from JSON
|
|
83
|
+
assert "--max-turns" in argv
|
|
84
|
+
assert "10" in argv # default_max_turns
|
|
85
|
+
assert "--max-budget-usd" in argv
|
|
86
|
+
assert "5.0" in argv # default_max_budget_usd
|
|
87
|
+
assert "--allowedTools" in argv
|
|
88
|
+
assert "--permission-mode" in argv
|
|
89
|
+
|
|
90
|
+
def test_session_id_flag(self, claude_provider):
|
|
91
|
+
task = ClaudeTaskSpec(prompt="x", session_id="sess-abc-123")
|
|
92
|
+
argv = claude_provider.headless_argv(task, defaults={})
|
|
93
|
+
assert "--session-id" in argv
|
|
94
|
+
assert "sess-abc-123" in argv
|
|
95
|
+
assert "--resume" not in argv
|
|
96
|
+
|
|
97
|
+
def test_resume_wins_over_session_id(self, claude_provider):
|
|
98
|
+
task = ClaudeTaskSpec(
|
|
99
|
+
prompt="x",
|
|
100
|
+
session_id="new-sess",
|
|
101
|
+
resume_session_id="prior-sess",
|
|
102
|
+
)
|
|
103
|
+
argv = claude_provider.headless_argv(task, defaults={})
|
|
104
|
+
assert "--resume" in argv
|
|
105
|
+
assert "prior-sess" in argv
|
|
106
|
+
assert "--session-id" not in argv
|
|
107
|
+
|
|
108
|
+
def test_zero_budget_omits_flag(self, claude_provider):
|
|
109
|
+
task = ClaudeTaskSpec(prompt="x", max_budget_usd=0.0)
|
|
110
|
+
argv = claude_provider.headless_argv(task, defaults={})
|
|
111
|
+
assert "--max-budget-usd" not in argv
|
|
112
|
+
|
|
113
|
+
def test_system_prompt_propagates(self, claude_provider):
|
|
114
|
+
task = ClaudeTaskSpec(prompt="x", system_prompt="be concise")
|
|
115
|
+
argv = claude_provider.headless_argv(task, defaults={})
|
|
116
|
+
assert "--append-system-prompt" in argv
|
|
117
|
+
assert "be concise" in argv
|
|
118
|
+
|
|
119
|
+
def test_wrong_task_type_raises(self, claude_provider):
|
|
120
|
+
codex_task = CodexTaskSpec(prompt="x")
|
|
121
|
+
with pytest.raises(TypeError, match="ClaudeTaskSpec"):
|
|
122
|
+
claude_provider.headless_argv(codex_task, defaults={})
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class TestClaudeParseEvent:
|
|
126
|
+
def test_valid_json_round_trips(self, claude_provider):
|
|
127
|
+
line = json.dumps({"type": "result", "result": "42", "session_id": "abc"})
|
|
128
|
+
event = claude_provider.parse_event(line)
|
|
129
|
+
assert event is not None
|
|
130
|
+
assert event["type"] == "result"
|
|
131
|
+
assert event["result"] == "42"
|
|
132
|
+
|
|
133
|
+
def test_garbage_returns_none(self, claude_provider):
|
|
134
|
+
assert claude_provider.parse_event("not json") is None
|
|
135
|
+
assert claude_provider.parse_event("") is None
|
|
136
|
+
assert claude_provider.parse_event(" ") is None
|
|
137
|
+
|
|
138
|
+
def test_is_final_event(self, claude_provider):
|
|
139
|
+
assert claude_provider.is_final_event({"type": "result"}) is True
|
|
140
|
+
assert claude_provider.is_final_event({"type": "assistant"}) is False
|
|
141
|
+
assert claude_provider.is_final_event({"type": "system"}) is False
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class TestClaudeEventToSessionResult:
|
|
145
|
+
"""Vendored Claude stream-json fixture verified end-to-end."""
|
|
146
|
+
|
|
147
|
+
@pytest.fixture
|
|
148
|
+
def fixture_events(self):
|
|
149
|
+
return [
|
|
150
|
+
{"type": "system", "subtype": "init", "session_id": "sess-1"},
|
|
151
|
+
{
|
|
152
|
+
"type": "assistant",
|
|
153
|
+
"message": {
|
|
154
|
+
"content": [
|
|
155
|
+
{"type": "text", "text": "Sure, here's..."},
|
|
156
|
+
{"type": "tool_use", "id": "t1", "name": "Read", "input": {}},
|
|
157
|
+
],
|
|
158
|
+
},
|
|
159
|
+
"session_id": "sess-1",
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
"type": "tool_use",
|
|
163
|
+
"tool_name": "Edit",
|
|
164
|
+
"tool_input": {"file_path": "x.py"},
|
|
165
|
+
},
|
|
166
|
+
{
|
|
167
|
+
"type": "result",
|
|
168
|
+
"subtype": "success",
|
|
169
|
+
"result": "Done — refactored to async.",
|
|
170
|
+
"total_cost_usd": 0.4231,
|
|
171
|
+
"duration_ms": 18234,
|
|
172
|
+
"num_turns": 7,
|
|
173
|
+
"session_id": "sess-1",
|
|
174
|
+
"usage": {
|
|
175
|
+
"input_tokens": 12000,
|
|
176
|
+
"output_tokens": 3500,
|
|
177
|
+
"cache_creation_input_tokens": 500,
|
|
178
|
+
"cache_read_input_tokens": 8000,
|
|
179
|
+
},
|
|
180
|
+
},
|
|
181
|
+
]
|
|
182
|
+
|
|
183
|
+
def test_reconstructs_response(self, claude_provider, fixture_events):
|
|
184
|
+
result = claude_provider.event_to_session_result(fixture_events, "", 0)
|
|
185
|
+
assert result["response"] == "Done — refactored to async."
|
|
186
|
+
|
|
187
|
+
def test_reconstructs_cost(self, claude_provider, fixture_events):
|
|
188
|
+
result = claude_provider.event_to_session_result(fixture_events, "", 0)
|
|
189
|
+
assert result["cost_usd"] == pytest.approx(0.4231)
|
|
190
|
+
|
|
191
|
+
def test_reconstructs_session_id(self, claude_provider, fixture_events):
|
|
192
|
+
result = claude_provider.event_to_session_result(fixture_events, "", 0)
|
|
193
|
+
assert result["session_id"] == "sess-1"
|
|
194
|
+
|
|
195
|
+
def test_reconstructs_duration_and_turns(self, claude_provider, fixture_events):
|
|
196
|
+
result = claude_provider.event_to_session_result(fixture_events, "", 0)
|
|
197
|
+
assert result["duration_ms"] == 18234
|
|
198
|
+
assert result["num_turns"] == 7
|
|
199
|
+
|
|
200
|
+
def test_canonical_usage_normalises(self, claude_provider, fixture_events):
|
|
201
|
+
cu: CanonicalUsage = claude_provider.canonical_usage(fixture_events)
|
|
202
|
+
assert cu.input_tokens == 12000
|
|
203
|
+
assert cu.output_tokens == 3500
|
|
204
|
+
assert cu.cache_read == 8000 # remapped from cache_read_input_tokens
|
|
205
|
+
assert cu.cache_write == 500 # remapped from cache_creation_input_tokens
|
|
206
|
+
assert cu.request_count == 7 # from num_turns
|
|
207
|
+
|
|
208
|
+
def test_counts_tool_calls(self, claude_provider, fixture_events):
|
|
209
|
+
# Two tool_use events: one inside an assistant message, one standalone
|
|
210
|
+
result = claude_provider.event_to_session_result(fixture_events, "", 0)
|
|
211
|
+
assert result["tool_calls"] >= 2
|
|
212
|
+
|
|
213
|
+
def test_success_on_zero_exit_with_result_event(self, claude_provider, fixture_events):
|
|
214
|
+
result = claude_provider.event_to_session_result(fixture_events, "", 0)
|
|
215
|
+
assert result["success"] is True
|
|
216
|
+
assert result["error"] is None
|
|
217
|
+
|
|
218
|
+
def test_failure_on_non_zero_exit(self, claude_provider, fixture_events):
|
|
219
|
+
result = claude_provider.event_to_session_result(
|
|
220
|
+
fixture_events, "exploded", 1,
|
|
221
|
+
)
|
|
222
|
+
assert result["success"] is False
|
|
223
|
+
assert "exploded" in (result["error"] or "")
|
|
224
|
+
|
|
225
|
+
def test_failure_on_missing_result_event(self, claude_provider):
|
|
226
|
+
events = [
|
|
227
|
+
{"type": "system", "subtype": "init", "session_id": "s"},
|
|
228
|
+
{"type": "assistant", "message": {}, "session_id": "s"},
|
|
229
|
+
]
|
|
230
|
+
result = claude_provider.event_to_session_result(events, "", 0)
|
|
231
|
+
assert result["success"] is False
|
|
232
|
+
assert "no result event" in (result["error"] or "")
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
class TestClaudeAuthDetection:
|
|
236
|
+
def test_logged_out_marker(self, claude_provider):
|
|
237
|
+
assert claude_provider.detect_auth_error(
|
|
238
|
+
"Please run 'claude login' first.", 1,
|
|
239
|
+
) is True
|
|
240
|
+
|
|
241
|
+
def test_clean_run_not_auth_error(self, claude_provider):
|
|
242
|
+
assert claude_provider.detect_auth_error("", 0) is False
|
|
243
|
+
|
|
244
|
+
def test_unrelated_stderr_not_auth_error(self, claude_provider):
|
|
245
|
+
assert claude_provider.detect_auth_error(
|
|
246
|
+
"git: pathspec 'x' did not match any files\n", 1,
|
|
247
|
+
) is False
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
class TestClaudeSupports:
|
|
251
|
+
def test_supports_full_feature_set(self, claude_provider):
|
|
252
|
+
for feature in (
|
|
253
|
+
"max_budget", "max_turns", "session_id", "resume",
|
|
254
|
+
"mcp_runtime", "json_cost", "ide_lockfile",
|
|
255
|
+
):
|
|
256
|
+
assert claude_provider.supports(feature), feature
|
|
257
|
+
|
|
258
|
+
def test_does_not_support_sandbox(self, claude_provider):
|
|
259
|
+
assert claude_provider.supports("sandbox") is False
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
# ---------------------------------------------------------------------------
|
|
263
|
+
# Codex provider
|
|
264
|
+
# ---------------------------------------------------------------------------
|
|
265
|
+
|
|
266
|
+
@pytest.fixture
|
|
267
|
+
def codex_provider():
|
|
268
|
+
return create_cli_provider("codex")
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
class TestCodexArgv:
|
|
272
|
+
def test_no_session_no_budget_no_turns(self, codex_provider):
|
|
273
|
+
task = CodexTaskSpec(prompt="hello")
|
|
274
|
+
argv = codex_provider.headless_argv(task, defaults={})
|
|
275
|
+
assert "--max-turns" not in argv
|
|
276
|
+
assert "--max-budget-usd" not in argv
|
|
277
|
+
assert "--session-id" not in argv
|
|
278
|
+
assert "--resume" not in argv
|
|
279
|
+
assert "--allowedTools" not in argv
|
|
280
|
+
|
|
281
|
+
def test_sandbox_flag(self, codex_provider):
|
|
282
|
+
task = CodexTaskSpec(prompt="x", sandbox="read-only")
|
|
283
|
+
argv = codex_provider.headless_argv(task, defaults={})
|
|
284
|
+
assert "--sandbox" in argv
|
|
285
|
+
assert "read-only" in argv
|
|
286
|
+
|
|
287
|
+
def test_ask_for_approval_flag(self, codex_provider):
|
|
288
|
+
task = CodexTaskSpec(prompt="x", ask_for_approval="on-request")
|
|
289
|
+
argv = codex_provider.headless_argv(task, defaults={})
|
|
290
|
+
assert "--ask-for-approval" in argv
|
|
291
|
+
assert "on-request" in argv
|
|
292
|
+
|
|
293
|
+
def test_default_sandbox_workspace_write(self, codex_provider):
|
|
294
|
+
task = CodexTaskSpec(prompt="x")
|
|
295
|
+
argv = codex_provider.headless_argv(task, defaults={})
|
|
296
|
+
idx = argv.index("--sandbox")
|
|
297
|
+
assert argv[idx + 1] == "workspace-write"
|
|
298
|
+
|
|
299
|
+
def test_system_prompt_prepended(self, codex_provider):
|
|
300
|
+
task = CodexTaskSpec(prompt="user thing", system_prompt="be careful")
|
|
301
|
+
argv = codex_provider.headless_argv(task, defaults={})
|
|
302
|
+
# Codex has no --system-prompt flag; we prepend with <system> tags
|
|
303
|
+
prompt_arg = argv[-1] # last arg is the prompt
|
|
304
|
+
assert "<system>" in prompt_arg
|
|
305
|
+
assert "be careful" in prompt_arg
|
|
306
|
+
assert "user thing" in prompt_arg
|
|
307
|
+
|
|
308
|
+
def test_wrong_task_type_raises(self, codex_provider):
|
|
309
|
+
claude_task = ClaudeTaskSpec(prompt="x")
|
|
310
|
+
with pytest.raises(TypeError, match="CodexTaskSpec"):
|
|
311
|
+
codex_provider.headless_argv(claude_task, defaults={})
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
class TestCodexEventReconstruction:
|
|
315
|
+
@pytest.fixture
|
|
316
|
+
def fixture_events(self):
|
|
317
|
+
# Codex has no public stream-json schema; this is a plausible
|
|
318
|
+
# synthetic stream that exercises our best-effort matchers.
|
|
319
|
+
return [
|
|
320
|
+
{"type": "message", "text": "Working on it..."},
|
|
321
|
+
{"type": "assistant", "text": "Final answer is X."},
|
|
322
|
+
{"type": "complete", "duration_ms": 5000, "stats": {"turns": 3}},
|
|
323
|
+
]
|
|
324
|
+
|
|
325
|
+
def test_extracts_response_from_final_event(self, codex_provider, fixture_events):
|
|
326
|
+
result = codex_provider.event_to_session_result(fixture_events, "", 0)
|
|
327
|
+
# Response comes from final event's `text`/`response`/`result`/`content`
|
|
328
|
+
# if present; otherwise the last assistant message.
|
|
329
|
+
assert "Final answer is X." in result["response"]
|
|
330
|
+
|
|
331
|
+
def test_cost_always_none_for_codex(self, codex_provider, fixture_events):
|
|
332
|
+
result = codex_provider.event_to_session_result(fixture_events, "", 0)
|
|
333
|
+
assert result["cost_usd"] is None
|
|
334
|
+
|
|
335
|
+
def test_canonical_usage_zeros(self, codex_provider, fixture_events):
|
|
336
|
+
cu = codex_provider.canonical_usage(fixture_events)
|
|
337
|
+
assert cu.input_tokens == 0
|
|
338
|
+
assert cu.output_tokens == 0
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
class TestCodexAuthDetection:
|
|
342
|
+
def test_openai_api_key_missing(self, codex_provider):
|
|
343
|
+
assert codex_provider.detect_auth_error(
|
|
344
|
+
"Error: OPENAI_API_KEY not set.\n", 1,
|
|
345
|
+
) is True
|
|
346
|
+
|
|
347
|
+
def test_401_marker(self, codex_provider):
|
|
348
|
+
assert codex_provider.detect_auth_error("HTTP 401 Unauthorized\n", 1) is True
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
class TestCodexSupports:
|
|
352
|
+
def test_supports_only_sandbox(self, codex_provider):
|
|
353
|
+
assert codex_provider.supports("sandbox") is True
|
|
354
|
+
for feature in (
|
|
355
|
+
"max_budget", "max_turns", "session_id", "resume",
|
|
356
|
+
"json_cost", "ide_lockfile",
|
|
357
|
+
):
|
|
358
|
+
assert codex_provider.supports(feature) is False, feature
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
"""`AICliService` tests — fail-fast paths that don't spawn the CLI.
|
|
2
|
+
|
|
3
|
+
The full subprocess-driven path is covered by live verification (see
|
|
4
|
+
`docs-internal/cli_agent_framework.md` → Verification §5–7). These unit
|
|
5
|
+
tests cover:
|
|
6
|
+
- `working_directory_not_git_repo` abort (no pool constructed)
|
|
7
|
+
- resolver contract: explicit `repo_root` doesn't fall back to cwd
|
|
8
|
+
- factory NotImplementedError surfaces cleanly
|
|
9
|
+
- cancel_workflow / cancel_node return zero when nothing's running
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
import tempfile
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from unittest.mock import MagicMock
|
|
18
|
+
|
|
19
|
+
import pytest
|
|
20
|
+
|
|
21
|
+
from services.cli_agent import ClaudeTaskSpec, CodexTaskSpec
|
|
22
|
+
from services.cli_agent.service import AICliService, get_ai_cli_service
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@pytest.mark.asyncio
|
|
26
|
+
async def test_not_git_repo_returns_structured_failure():
|
|
27
|
+
"""Caller-supplied repo_root that isn't a git repo → fail-fast,
|
|
28
|
+
every task surfaces `working_directory_not_git_repo`."""
|
|
29
|
+
svc = AICliService()
|
|
30
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
31
|
+
result = await svc.run_batch(
|
|
32
|
+
"claude",
|
|
33
|
+
tasks=[
|
|
34
|
+
ClaudeTaskSpec(prompt="task A"),
|
|
35
|
+
ClaudeTaskSpec(prompt="task B"),
|
|
36
|
+
],
|
|
37
|
+
node_id="n",
|
|
38
|
+
workflow_id="wf",
|
|
39
|
+
workspace_dir=Path(tmp),
|
|
40
|
+
broadcaster=None,
|
|
41
|
+
repo_root=Path(tmp), # explicit non-git
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
assert result.n_tasks == 2
|
|
45
|
+
assert result.n_succeeded == 0
|
|
46
|
+
assert result.n_failed == 2
|
|
47
|
+
assert all(t.error == "working_directory_not_git_repo" for t in result.tasks)
|
|
48
|
+
assert result.total_cost_usd is None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@pytest.mark.asyncio
|
|
52
|
+
async def test_explicit_repo_root_does_not_fallback_to_cwd():
|
|
53
|
+
"""When the caller passes an explicit `repo_root`, the resolver
|
|
54
|
+
must NOT walk to cwd. This is the bug we fixed during Phase 5
|
|
55
|
+
smoke-testing."""
|
|
56
|
+
# cwd is the framework worktree (a git repo). If the resolver fell
|
|
57
|
+
# back, it would silently succeed — bad.
|
|
58
|
+
svc = AICliService()
|
|
59
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
60
|
+
result = await svc.run_batch(
|
|
61
|
+
"claude",
|
|
62
|
+
tasks=[ClaudeTaskSpec(prompt="x")],
|
|
63
|
+
node_id="n",
|
|
64
|
+
workflow_id="wf",
|
|
65
|
+
workspace_dir=Path(tmp),
|
|
66
|
+
broadcaster=None,
|
|
67
|
+
repo_root=Path(tmp),
|
|
68
|
+
)
|
|
69
|
+
assert result.n_failed == 1
|
|
70
|
+
assert result.tasks[0].error == "working_directory_not_git_repo"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@pytest.mark.asyncio
|
|
74
|
+
async def test_codex_provider_works():
|
|
75
|
+
"""Codex factory must build a provider; happy-path argv was already
|
|
76
|
+
covered in test_providers.py."""
|
|
77
|
+
svc = AICliService()
|
|
78
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
79
|
+
# Same not-git-repo path with Codex — confirms the provider
|
|
80
|
+
# discrim works through the service.
|
|
81
|
+
result = await svc.run_batch(
|
|
82
|
+
"codex",
|
|
83
|
+
tasks=[CodexTaskSpec(prompt="x", sandbox="read-only")],
|
|
84
|
+
node_id="n",
|
|
85
|
+
workflow_id="wf",
|
|
86
|
+
workspace_dir=Path(tmp),
|
|
87
|
+
broadcaster=None,
|
|
88
|
+
repo_root=Path(tmp),
|
|
89
|
+
)
|
|
90
|
+
assert result.provider == "codex"
|
|
91
|
+
assert result.tasks[0].provider == "codex"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@pytest.mark.asyncio
|
|
95
|
+
async def test_gemini_factory_raises_not_implemented():
|
|
96
|
+
svc = AICliService()
|
|
97
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
98
|
+
with pytest.raises(NotImplementedError, match="deferred to v2"):
|
|
99
|
+
await svc.run_batch(
|
|
100
|
+
"gemini",
|
|
101
|
+
tasks=[], # spec irrelevant — factory raises before construction
|
|
102
|
+
node_id="n",
|
|
103
|
+
workflow_id="wf",
|
|
104
|
+
workspace_dir=Path(tmp),
|
|
105
|
+
broadcaster=None,
|
|
106
|
+
repo_root=Path(tmp),
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@pytest.mark.asyncio
|
|
111
|
+
async def test_cancel_when_no_active_pools():
|
|
112
|
+
svc = AICliService()
|
|
113
|
+
assert await svc.cancel_workflow("nothing") == 0
|
|
114
|
+
assert await svc.cancel_node("nothing") == 0
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def test_singleton_accessor_returns_same_instance():
|
|
118
|
+
a = get_ai_cli_service()
|
|
119
|
+
b = get_ai_cli_service()
|
|
120
|
+
assert a is b
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@pytest.mark.asyncio
|
|
124
|
+
async def test_resolver_walks_upward_to_find_git():
|
|
125
|
+
"""Without `override`, the resolver tries workspace_dir then cwd.
|
|
126
|
+
|
|
127
|
+
The cli-agent-framework worktree is a git repo. A deep child path
|
|
128
|
+
should resolve via `git rev-parse --show-toplevel` to the worktree root.
|
|
129
|
+
"""
|
|
130
|
+
deep = Path(__file__).resolve().parent / "deep" / "deeper"
|
|
131
|
+
root = await AICliService._resolve_repo_root(workspace_dir=deep, override=None)
|
|
132
|
+
assert root is not None
|
|
133
|
+
assert (root / ".git").exists()
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@pytest.mark.asyncio
|
|
137
|
+
async def test_resolver_returns_none_when_override_not_git():
|
|
138
|
+
import tempfile
|
|
139
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
140
|
+
root = await AICliService._resolve_repo_root(
|
|
141
|
+
workspace_dir=Path(tmp), # ignored when override is set
|
|
142
|
+
override=Path(tmp),
|
|
143
|
+
)
|
|
144
|
+
assert root is None
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
# ---------------------------------------------------------------------------
|
|
148
|
+
# Execution-engine integration: drive the real `run_batch` (via the DI
|
|
149
|
+
# accessor) and assert the `[CC-Agent ...]` log lines fire at the right
|
|
150
|
+
# transitions. This replaces direct contextvar pokes — every assertion
|
|
151
|
+
# walks the same resolver / register_batch / session-start path the
|
|
152
|
+
# production code does.
|
|
153
|
+
#
|
|
154
|
+
# The top-level `tests/conftest.py` stubs `core.logging.get_logger` with
|
|
155
|
+
# a shared MagicMock, so we can't use `caplog`. Instead each module's
|
|
156
|
+
# module-level ``logger`` IS that shared MagicMock — we inspect its
|
|
157
|
+
# ``info``/``warning`` ``call_args_list`` to verify the diagnostic
|
|
158
|
+
# chain fires.
|
|
159
|
+
# ---------------------------------------------------------------------------
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _logger_messages(*module_loggers) -> str:
|
|
163
|
+
"""Concatenate every ``.info`` / ``.warning`` / ``.error`` call's
|
|
164
|
+
rendered template+args across one or more module-level loggers."""
|
|
165
|
+
out: list[str] = []
|
|
166
|
+
for lg in module_loggers:
|
|
167
|
+
for level in ("info", "warning", "error", "exception", "debug"):
|
|
168
|
+
method = getattr(lg, level, None)
|
|
169
|
+
if method is None or not hasattr(method, "call_args_list"):
|
|
170
|
+
continue
|
|
171
|
+
for call in method.call_args_list:
|
|
172
|
+
args = call.args or ()
|
|
173
|
+
if not args:
|
|
174
|
+
continue
|
|
175
|
+
template = args[0]
|
|
176
|
+
if not isinstance(template, str):
|
|
177
|
+
out.append(repr(template))
|
|
178
|
+
continue
|
|
179
|
+
try:
|
|
180
|
+
out.append(template % args[1:] if len(args) > 1 else template)
|
|
181
|
+
except (TypeError, ValueError):
|
|
182
|
+
out.append(template + " | " + repr(args[1:]))
|
|
183
|
+
return "\n".join(out)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
@pytest.mark.asyncio
|
|
187
|
+
async def test_run_batch_emits_diagnostic_logs_when_workspace_not_git(monkeypatch):
|
|
188
|
+
"""Abort path: `run_batch` enters, fails the resolver, returns
|
|
189
|
+
structured failure. The `[CC-Agent run_batch] enter` and
|
|
190
|
+
`[CC-Agent run_batch] aborting` log lines must fire so the operator
|
|
191
|
+
sees WHY the batch never reached `register_batch`."""
|
|
192
|
+
# Force the module-level logger to a Mock for THIS test, regardless of
|
|
193
|
+
# whether conftest's `core.logging.get_logger` stub was active at the
|
|
194
|
+
# moment the service module was first imported. In the full-suite
|
|
195
|
+
# ordering some sibling test imports `core.logging` after the stub is
|
|
196
|
+
# placed, leaving the cli_agent module with a real structlog
|
|
197
|
+
# BoundLogger that has no `reset_mock`. Locally patching makes the test
|
|
198
|
+
# robust to import-order pollution.
|
|
199
|
+
from services.cli_agent import service as svc_mod
|
|
200
|
+
svc_logger = MagicMock()
|
|
201
|
+
monkeypatch.setattr(svc_mod, "logger", svc_logger)
|
|
202
|
+
|
|
203
|
+
svc = get_ai_cli_service() # DI singleton — same accessor production uses
|
|
204
|
+
import tempfile
|
|
205
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
206
|
+
result = await svc.run_batch(
|
|
207
|
+
"claude",
|
|
208
|
+
tasks=[ClaudeTaskSpec(prompt="hello")],
|
|
209
|
+
node_id="ccode_test_abort",
|
|
210
|
+
workflow_id="wf_abort",
|
|
211
|
+
workspace_dir=Path(tmp),
|
|
212
|
+
broadcaster=None,
|
|
213
|
+
repo_root=Path(tmp),
|
|
214
|
+
connected_tools=[
|
|
215
|
+
{"node_id": "ddg_1", "node_type": "duckduckgoSearch",
|
|
216
|
+
"label": "DDG", "parameters": {}},
|
|
217
|
+
],
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
text = _logger_messages(svc_logger)
|
|
221
|
+
assert "[CC-Agent run_batch] enter" in text, text
|
|
222
|
+
assert "duckduckgoSearch" in text, text
|
|
223
|
+
assert "[CC-Agent run_batch] aborting" in text, text
|
|
224
|
+
# The abort path MUST NOT register a batch (no MCP tokens leaked):
|
|
225
|
+
assert "[CC-Agent MCP register_batch]" not in text, text
|
|
226
|
+
assert result.n_failed == 1
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
@pytest.mark.asyncio
|
|
230
|
+
async def test_run_batch_registers_mcp_batch_on_happy_path(monkeypatch):
|
|
231
|
+
"""Happy path: real resolver, real `register_batch`, but
|
|
232
|
+
`AICliSession.start` is short-circuited so we don't spawn `claude`.
|
|
233
|
+
Asserts the `[CC-Agent MCP register_batch]` log fires with the
|
|
234
|
+
expected tool list — proving the diagnostic chain is intact
|
|
235
|
+
end-to-end and the spawned CLI WOULD see the MCP server registered
|
|
236
|
+
for it."""
|
|
237
|
+
from services.cli_agent import session as session_mod
|
|
238
|
+
from services.cli_agent import service as svc_mod
|
|
239
|
+
from services.cli_agent import mcp_server as mcp_mod
|
|
240
|
+
from services.cli_agent.protocol import SessionResult
|
|
241
|
+
|
|
242
|
+
# See the abort-path test for the rationale — patch the module-level
|
|
243
|
+
# logger directly so the test does not depend on conftest's
|
|
244
|
+
# `core.logging.get_logger` stub being active at import time.
|
|
245
|
+
svc_logger = MagicMock()
|
|
246
|
+
mcp_logger = MagicMock()
|
|
247
|
+
monkeypatch.setattr(svc_mod, "logger", svc_logger)
|
|
248
|
+
monkeypatch.setattr(mcp_mod, "logger", mcp_logger)
|
|
249
|
+
|
|
250
|
+
started = {"count": 0}
|
|
251
|
+
|
|
252
|
+
async def _fake_start(self): # noqa: ANN001
|
|
253
|
+
started["count"] += 1
|
|
254
|
+
self._completed = True
|
|
255
|
+
|
|
256
|
+
async def _fake_wait(self, timeout): # noqa: ANN001, ARG002
|
|
257
|
+
return SessionResult(
|
|
258
|
+
task_id=self.task_id, provider=self._provider.name,
|
|
259
|
+
prompt=getattr(self._task, "prompt", ""),
|
|
260
|
+
success=True, response="stub",
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
async def _fake_cleanup(self): # noqa: ANN001
|
|
264
|
+
pass
|
|
265
|
+
|
|
266
|
+
monkeypatch.setattr(session_mod.AICliSession, "start", _fake_start)
|
|
267
|
+
monkeypatch.setattr(session_mod.AICliSession, "wait_for_completion", _fake_wait)
|
|
268
|
+
monkeypatch.setattr(session_mod.AICliSession, "cleanup", _fake_cleanup)
|
|
269
|
+
|
|
270
|
+
svc = get_ai_cli_service()
|
|
271
|
+
workspace = Path(__file__).resolve().parents[3] # the repo root (a git repo)
|
|
272
|
+
result = await svc.run_batch(
|
|
273
|
+
"claude",
|
|
274
|
+
tasks=[ClaudeTaskSpec(prompt="ping")],
|
|
275
|
+
node_id="ccode_test_happy",
|
|
276
|
+
workflow_id="wf_happy",
|
|
277
|
+
workspace_dir=workspace,
|
|
278
|
+
broadcaster=None,
|
|
279
|
+
repo_root=None, # let the resolver find the parent .git
|
|
280
|
+
connected_tools=[
|
|
281
|
+
{"node_id": "ddg_1", "node_type": "duckduckgoSearch",
|
|
282
|
+
"label": "DDG", "parameters": {}},
|
|
283
|
+
],
|
|
284
|
+
connected_skill_names=["duckduckgo-search-skill"],
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
text = _logger_messages(svc_logger, mcp_logger)
|
|
288
|
+
# Engine-entry log:
|
|
289
|
+
assert "[CC-Agent run_batch] enter" in text, text
|
|
290
|
+
assert "duckduckgoSearch" in text, text
|
|
291
|
+
# Resolver succeeded:
|
|
292
|
+
assert "[CC-Agent run_batch] resolved repo_root=" in text, text
|
|
293
|
+
# MCP token registered with our connected tool:
|
|
294
|
+
assert "[CC-Agent MCP register_batch]" in text, text
|
|
295
|
+
# Session was actually exercised (proves the engine ran the inner
|
|
296
|
+
# gather, not just the abort path):
|
|
297
|
+
assert started["count"] == 1
|
|
298
|
+
assert result.n_succeeded == 1
|
|
File without changes
|