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,188 @@
|
|
|
1
|
+
"""Coverage for ``services.memory.jsonl`` — parse / append / trim.
|
|
2
|
+
|
|
3
|
+
Anthropic Messages API JSONL is the storage format the
|
|
4
|
+
``claude_code_agent`` bridge round-trips. These tests pin the public
|
|
5
|
+
contract: standard parsers ignore unknown metadata, append always
|
|
6
|
+
emits a trailing newline, and trim returns removed lines verbatim for
|
|
7
|
+
vector-store archival.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
|
|
14
|
+
from langchain_core.messages import AIMessage, HumanMessage
|
|
15
|
+
|
|
16
|
+
from services.memory.jsonl import append_message, parse_jsonl, trim_window
|
|
17
|
+
from services.memory import ( # public re-exports
|
|
18
|
+
parse_jsonl as parse_jsonl_reexport,
|
|
19
|
+
append_message as append_message_reexport,
|
|
20
|
+
trim_window as trim_window_reexport,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# ---------------------------------------------------------------------------
|
|
25
|
+
# parse_jsonl
|
|
26
|
+
# ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_parse_jsonl_empty_input_returns_empty_list():
|
|
30
|
+
assert parse_jsonl("") == []
|
|
31
|
+
assert parse_jsonl(None) == [] # type: ignore[arg-type]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_parse_jsonl_basic_user_assistant_pair():
|
|
35
|
+
text = (
|
|
36
|
+
'{"role": "user", "content": "hi"}\n'
|
|
37
|
+
'{"role": "assistant", "content": "hello"}\n'
|
|
38
|
+
)
|
|
39
|
+
msgs = parse_jsonl(text)
|
|
40
|
+
assert len(msgs) == 2
|
|
41
|
+
assert isinstance(msgs[0], HumanMessage) and msgs[0].content == "hi"
|
|
42
|
+
assert isinstance(msgs[1], AIMessage) and msgs[1].content == "hello"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_parse_jsonl_skips_unparseable_lines_forward_compat():
|
|
46
|
+
text = (
|
|
47
|
+
'{"role": "user", "content": "ok"}\n'
|
|
48
|
+
"this is not json\n"
|
|
49
|
+
'{"role": "assistant", "content": "still ok"}\n'
|
|
50
|
+
)
|
|
51
|
+
msgs = parse_jsonl(text)
|
|
52
|
+
assert [m.content for m in msgs] == ["ok", "still ok"]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_parse_jsonl_skips_unknown_roles():
|
|
56
|
+
text = (
|
|
57
|
+
'{"role": "system", "content": "unknown role"}\n'
|
|
58
|
+
'{"role": "tool_use", "content": "skipped"}\n'
|
|
59
|
+
'{"role": "user", "content": "kept"}\n'
|
|
60
|
+
)
|
|
61
|
+
msgs = parse_jsonl(text)
|
|
62
|
+
assert [m.content for m in msgs] == ["kept"]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_parse_jsonl_collapses_content_blocks_to_text():
|
|
66
|
+
text = json.dumps({
|
|
67
|
+
"role": "assistant",
|
|
68
|
+
"content": [
|
|
69
|
+
{"type": "text", "text": "Got it"},
|
|
70
|
+
{"type": "tool_use", "id": "tu_1", "name": "search", "input": {}},
|
|
71
|
+
{"type": "text", "text": "blue."},
|
|
72
|
+
],
|
|
73
|
+
}) + "\n"
|
|
74
|
+
msgs = parse_jsonl(text)
|
|
75
|
+
assert len(msgs) == 1
|
|
76
|
+
assert msgs[0].content == "Got it blue." # text blocks joined; tool_use dropped
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_parse_jsonl_metadata_keys_are_ignored():
|
|
80
|
+
text = json.dumps({
|
|
81
|
+
"role": "user",
|
|
82
|
+
"content": "hi",
|
|
83
|
+
"timestamp": "2026-05-10T00:00:00Z",
|
|
84
|
+
"session_id": "abc-123",
|
|
85
|
+
"model": "claude",
|
|
86
|
+
}) + "\n"
|
|
87
|
+
msgs = parse_jsonl(text)
|
|
88
|
+
assert len(msgs) == 1 and msgs[0].content == "hi"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
# ---------------------------------------------------------------------------
|
|
92
|
+
# append_message
|
|
93
|
+
# ---------------------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def test_append_message_to_empty_string_yields_single_line_with_newline():
|
|
97
|
+
out = append_message("", "user", "hi")
|
|
98
|
+
assert out == '{"role": "user", "content": "hi"}\n'
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def test_append_message_chains_cleanly_across_calls():
|
|
102
|
+
text = ""
|
|
103
|
+
text = append_message(text, "user", "q1")
|
|
104
|
+
text = append_message(text, "assistant", "a1")
|
|
105
|
+
lines = [ln for ln in text.splitlines() if ln.strip()]
|
|
106
|
+
assert len(lines) == 2
|
|
107
|
+
assert json.loads(lines[0])["role"] == "user"
|
|
108
|
+
assert json.loads(lines[1])["role"] == "assistant"
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def test_append_message_normalises_missing_trailing_newline():
|
|
112
|
+
base = '{"role": "user", "content": "hi"}' # no trailing newline
|
|
113
|
+
out = append_message(base, "assistant", "ok")
|
|
114
|
+
assert out.startswith('{"role": "user", "content": "hi"}\n')
|
|
115
|
+
assert out.endswith('"content": "ok"}\n')
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def test_append_message_metadata_round_trips_via_parse_jsonl():
|
|
119
|
+
text = append_message(
|
|
120
|
+
"", "assistant", "blue.",
|
|
121
|
+
timestamp="2026-05-10T12:34:56+00:00",
|
|
122
|
+
session_id="abc-123",
|
|
123
|
+
model="claude",
|
|
124
|
+
)
|
|
125
|
+
obj = json.loads(text.strip())
|
|
126
|
+
assert obj["timestamp"] == "2026-05-10T12:34:56+00:00"
|
|
127
|
+
assert obj["session_id"] == "abc-123"
|
|
128
|
+
assert obj["model"] == "claude"
|
|
129
|
+
# parse_jsonl still returns the message; metadata is preserved on the
|
|
130
|
+
# wire even if it's not surfaced on BaseMessage.
|
|
131
|
+
msgs = parse_jsonl(text)
|
|
132
|
+
assert msgs[0].content == "blue."
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def test_append_message_supports_non_ascii_content():
|
|
136
|
+
out = append_message("", "user", "héllo — 你好")
|
|
137
|
+
obj = json.loads(out.strip())
|
|
138
|
+
assert obj["content"] == "héllo — 你好" # ensure_ascii=False preserves UTF-8
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
# ---------------------------------------------------------------------------
|
|
142
|
+
# trim_window
|
|
143
|
+
# ---------------------------------------------------------------------------
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def test_trim_window_under_capacity_returns_input_unchanged_and_no_removed():
|
|
147
|
+
text = ""
|
|
148
|
+
text = append_message(text, "user", "q1")
|
|
149
|
+
text = append_message(text, "assistant", "a1")
|
|
150
|
+
trimmed, removed = trim_window(text, window_size=2)
|
|
151
|
+
assert trimmed == text
|
|
152
|
+
assert removed == []
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def test_trim_window_removes_oldest_pairs_first():
|
|
156
|
+
text = ""
|
|
157
|
+
text = append_message(text, "user", "q1")
|
|
158
|
+
text = append_message(text, "assistant", "a1")
|
|
159
|
+
text = append_message(text, "user", "q2")
|
|
160
|
+
text = append_message(text, "assistant", "a2")
|
|
161
|
+
text = append_message(text, "user", "q3")
|
|
162
|
+
text = append_message(text, "assistant", "a3")
|
|
163
|
+
trimmed, removed = trim_window(text, window_size=1)
|
|
164
|
+
# window=1 keeps last 2 lines; removes 4 oldest.
|
|
165
|
+
assert len(removed) == 4
|
|
166
|
+
kept = [json.loads(ln) for ln in trimmed.splitlines() if ln.strip()]
|
|
167
|
+
assert [k["content"] for k in kept] == ["q3", "a3"]
|
|
168
|
+
# Removed entries are returned verbatim so the vector store gets
|
|
169
|
+
# the raw JSONL line back.
|
|
170
|
+
removed_objs = [json.loads(ln) for ln in removed]
|
|
171
|
+
assert [o["content"] for o in removed_objs] == ["q1", "a1", "q2", "a2"]
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def test_trim_window_handles_empty_text():
|
|
175
|
+
trimmed, removed = trim_window("", 5)
|
|
176
|
+
assert trimmed == "" and removed == []
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
# ---------------------------------------------------------------------------
|
|
180
|
+
# Public-API re-exports through services.memory
|
|
181
|
+
# ---------------------------------------------------------------------------
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def test_services_memory_reexports_match_jsonl_module():
|
|
185
|
+
"""`services.memory.__init__` re-exports the JSONL public surface."""
|
|
186
|
+
assert parse_jsonl_reexport is parse_jsonl
|
|
187
|
+
assert append_message_reexport is append_message
|
|
188
|
+
assert trim_window_reexport is trim_window
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
"""Contract tests for the generalized event-source framework."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import base64
|
|
7
|
+
import hashlib
|
|
8
|
+
import hmac
|
|
9
|
+
import json
|
|
10
|
+
import time
|
|
11
|
+
from typing import Iterable
|
|
12
|
+
from unittest.mock import patch
|
|
13
|
+
|
|
14
|
+
import pytest
|
|
15
|
+
|
|
16
|
+
pytestmark = pytest.mark.node_contract
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _run(coro):
|
|
20
|
+
return asyncio.new_event_loop().run_until_complete(coro)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# ============================================================================
|
|
24
|
+
# WorkflowEvent envelope
|
|
25
|
+
# ============================================================================
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class TestWorkflowEvent:
|
|
29
|
+
def test_required_fields(self):
|
|
30
|
+
from services.events import WorkflowEvent
|
|
31
|
+
ev = WorkflowEvent(source="stripe://acct_1", type="stripe.charge.succeeded")
|
|
32
|
+
assert ev.specversion == "1.0"
|
|
33
|
+
assert ev.id # uuid default
|
|
34
|
+
assert ev.datacontenttype == "application/json"
|
|
35
|
+
|
|
36
|
+
def test_round_trip_json(self):
|
|
37
|
+
from services.events import WorkflowEvent
|
|
38
|
+
ev = WorkflowEvent(
|
|
39
|
+
source="stripe://acct_1", type="stripe.charge.succeeded", data={"amount": 1000},
|
|
40
|
+
)
|
|
41
|
+
restored = WorkflowEvent.model_validate(json.loads(ev.model_dump_json()))
|
|
42
|
+
assert restored.type == ev.type
|
|
43
|
+
assert restored.data == {"amount": 1000}
|
|
44
|
+
assert restored.id == ev.id
|
|
45
|
+
|
|
46
|
+
def test_matches_type_glob(self):
|
|
47
|
+
from services.events import WorkflowEvent
|
|
48
|
+
ev = WorkflowEvent(source="stripe://x", type="stripe.charge.succeeded")
|
|
49
|
+
assert ev.matches_type("all") is True
|
|
50
|
+
assert ev.matches_type("") is True
|
|
51
|
+
assert ev.matches_type("stripe.charge.succeeded") is True
|
|
52
|
+
assert ev.matches_type("stripe.charge.*") is True
|
|
53
|
+
assert ev.matches_type("stripe.*") is True
|
|
54
|
+
assert ev.matches_type("payment_intent.*") is False
|
|
55
|
+
|
|
56
|
+
def test_from_legacy(self):
|
|
57
|
+
from services.events import WorkflowEvent
|
|
58
|
+
ev = WorkflowEvent.from_legacy("whatsapp_message_received", {"text": "hi"})
|
|
59
|
+
assert ev.type == "whatsapp_message_received"
|
|
60
|
+
assert ev.data == {"text": "hi"}
|
|
61
|
+
assert ev.source.startswith("legacy://")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# ============================================================================
|
|
65
|
+
# Verifiers
|
|
66
|
+
# ============================================================================
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class TestStripeVerifier:
|
|
70
|
+
SECRET = "whsec_test"
|
|
71
|
+
|
|
72
|
+
def _sign(self, body: bytes, ts: int | None = None) -> dict:
|
|
73
|
+
ts = ts if ts is not None else int(time.time())
|
|
74
|
+
signed = f"{ts}.".encode() + body
|
|
75
|
+
sig = hmac.new(self.SECRET.encode(), signed, hashlib.sha256).hexdigest()
|
|
76
|
+
return {"Stripe-Signature": f"t={ts},v1={sig}"}
|
|
77
|
+
|
|
78
|
+
def test_valid_signature_passes(self):
|
|
79
|
+
from services.events import StripeVerifier
|
|
80
|
+
body = b'{"id":"evt_1"}'
|
|
81
|
+
StripeVerifier.verify(self._sign(body), body, self.SECRET)
|
|
82
|
+
|
|
83
|
+
def test_tampered_body_rejected(self):
|
|
84
|
+
from services.events import StripeVerifier
|
|
85
|
+
body = b'{"id":"evt_1"}'
|
|
86
|
+
headers = self._sign(body)
|
|
87
|
+
with pytest.raises(ValueError):
|
|
88
|
+
StripeVerifier.verify(headers, b'{"id":"evt_2"}', self.SECRET)
|
|
89
|
+
|
|
90
|
+
def test_missing_header_rejected(self):
|
|
91
|
+
from services.events import StripeVerifier
|
|
92
|
+
with pytest.raises(ValueError, match="missing"):
|
|
93
|
+
StripeVerifier.verify({}, b'{}', self.SECRET)
|
|
94
|
+
|
|
95
|
+
def test_malformed_header_rejected(self):
|
|
96
|
+
from services.events import StripeVerifier
|
|
97
|
+
with pytest.raises(ValueError):
|
|
98
|
+
StripeVerifier.verify({"Stripe-Signature": "garbage"}, b'{}', self.SECRET)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class TestGitHubVerifier:
|
|
102
|
+
def test_round_trip(self):
|
|
103
|
+
from services.events import GitHubVerifier
|
|
104
|
+
secret = "shh"
|
|
105
|
+
body = b'{"action":"opened"}'
|
|
106
|
+
sig = "sha256=" + hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
|
|
107
|
+
GitHubVerifier.verify({"X-Hub-Signature-256": sig}, body, secret)
|
|
108
|
+
|
|
109
|
+
def test_tampered_rejected(self):
|
|
110
|
+
from services.events import GitHubVerifier
|
|
111
|
+
with pytest.raises(ValueError):
|
|
112
|
+
GitHubVerifier.verify({"X-Hub-Signature-256": "sha256=deadbeef"}, b'{}', "shh")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class TestStandardWebhooksVerifier:
|
|
116
|
+
def test_round_trip(self):
|
|
117
|
+
from services.events import StandardWebhooksVerifier
|
|
118
|
+
secret_raw = b"super-secret-key-bytes"
|
|
119
|
+
secret = "whsec_" + base64.b64encode(secret_raw).decode()
|
|
120
|
+
body = b'{"foo":"bar"}'
|
|
121
|
+
msg_id = "msg_abc"
|
|
122
|
+
ts = "1700000000"
|
|
123
|
+
signed = f"{msg_id}.{ts}.".encode() + body
|
|
124
|
+
sig = base64.b64encode(hmac.new(secret_raw, signed, hashlib.sha256).digest()).decode()
|
|
125
|
+
headers = {
|
|
126
|
+
"webhook-id": msg_id,
|
|
127
|
+
"webhook-timestamp": ts,
|
|
128
|
+
"webhook-signature": f"v1,{sig}",
|
|
129
|
+
}
|
|
130
|
+
StandardWebhooksVerifier.verify(headers, body, secret)
|
|
131
|
+
|
|
132
|
+
def test_tampered_rejected(self):
|
|
133
|
+
from services.events import StandardWebhooksVerifier
|
|
134
|
+
with pytest.raises(ValueError):
|
|
135
|
+
StandardWebhooksVerifier.verify(
|
|
136
|
+
{"webhook-id": "1", "webhook-timestamp": "1", "webhook-signature": "v1,bad"},
|
|
137
|
+
b'{}', "whsec_AAAA",
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class TestHmacVerifier:
|
|
142
|
+
def test_round_trip(self):
|
|
143
|
+
from services.events import HmacVerifier
|
|
144
|
+
secret = "shh"
|
|
145
|
+
body = b'{"x":1}'
|
|
146
|
+
sig = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
|
|
147
|
+
HmacVerifier.verify({"X-Signature-256": sig}, body, secret)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
# ============================================================================
|
|
151
|
+
# PollingEventSource loop
|
|
152
|
+
# ============================================================================
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class TestPollingEventSource:
|
|
156
|
+
def test_emits_what_poll_once_returns_then_sleeps(self):
|
|
157
|
+
from services.events import PollingEventSource, WorkflowEvent
|
|
158
|
+
|
|
159
|
+
class FakePolling(PollingEventSource):
|
|
160
|
+
type = "fake.polling"
|
|
161
|
+
poll_interval_default = 0 # tight loop for the test
|
|
162
|
+
|
|
163
|
+
def __init__(self):
|
|
164
|
+
super().__init__()
|
|
165
|
+
self.calls = 0
|
|
166
|
+
|
|
167
|
+
async def poll_once(self, state) -> Iterable[WorkflowEvent]:
|
|
168
|
+
self.calls += 1
|
|
169
|
+
if self.calls == 1:
|
|
170
|
+
return [WorkflowEvent(source="x", type="fake.tick", data={"n": 1})]
|
|
171
|
+
self._stopped = True
|
|
172
|
+
return []
|
|
173
|
+
|
|
174
|
+
async def drain():
|
|
175
|
+
src = FakePolling()
|
|
176
|
+
seen = []
|
|
177
|
+
async for ev in src.emit():
|
|
178
|
+
seen.append(ev)
|
|
179
|
+
if len(seen) >= 1:
|
|
180
|
+
src._stopped = True
|
|
181
|
+
return seen
|
|
182
|
+
|
|
183
|
+
events = _run(drain())
|
|
184
|
+
assert len(events) == 1
|
|
185
|
+
assert events[0].type == "fake.tick"
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
# ============================================================================
|
|
189
|
+
# WebhookSource — handle() integration
|
|
190
|
+
# ============================================================================
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
class TestWebhookSourceHandle:
|
|
194
|
+
def _build_source(self, secret_value: str | None):
|
|
195
|
+
from services.events import StripeVerifier, WebhookSource, WorkflowEvent
|
|
196
|
+
|
|
197
|
+
class _Cred:
|
|
198
|
+
@classmethod
|
|
199
|
+
async def resolve(cls):
|
|
200
|
+
if secret_value is None:
|
|
201
|
+
raise PermissionError
|
|
202
|
+
return {"api_key": "sk_test", "test_secret": secret_value}
|
|
203
|
+
|
|
204
|
+
class FakeSource(WebhookSource):
|
|
205
|
+
type = "fake.hook"
|
|
206
|
+
path = "fake"
|
|
207
|
+
verifier = StripeVerifier
|
|
208
|
+
secret_field = "test_secret"
|
|
209
|
+
credential = _Cred
|
|
210
|
+
|
|
211
|
+
async def shape(self, request, body, payload):
|
|
212
|
+
return WorkflowEvent(source="fake://x", type="fake.event", data=payload)
|
|
213
|
+
|
|
214
|
+
return FakeSource()
|
|
215
|
+
|
|
216
|
+
def _signed_request(self, body: bytes, secret: str):
|
|
217
|
+
ts = int(time.time())
|
|
218
|
+
signed = f"{ts}.".encode() + body
|
|
219
|
+
sig = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
|
|
220
|
+
|
|
221
|
+
class FakeReq:
|
|
222
|
+
headers = {"Stripe-Signature": f"t={ts},v1={sig}"}
|
|
223
|
+
|
|
224
|
+
async def body(self):
|
|
225
|
+
return body
|
|
226
|
+
|
|
227
|
+
return FakeReq()
|
|
228
|
+
|
|
229
|
+
def test_valid_signature_dispatches(self):
|
|
230
|
+
src = self._build_source(secret_value="whsec_test")
|
|
231
|
+
body = b'{"event":"payload"}'
|
|
232
|
+
req = self._signed_request(body, "whsec_test")
|
|
233
|
+
with patch("services.event_waiter.dispatch") as dispatch:
|
|
234
|
+
ev = _run(src.handle(req))
|
|
235
|
+
assert ev.type == "fake.event"
|
|
236
|
+
dispatch.assert_called_once()
|
|
237
|
+
called_type, called_event = dispatch.call_args[0]
|
|
238
|
+
assert called_type == "fake.hook"
|
|
239
|
+
assert called_event is ev
|
|
240
|
+
|
|
241
|
+
def test_tampered_signature_raises_400(self):
|
|
242
|
+
from fastapi import HTTPException
|
|
243
|
+
src = self._build_source(secret_value="whsec_test")
|
|
244
|
+
body = b'{"event":"payload"}'
|
|
245
|
+
req = self._signed_request(body, "whsec_other") # signed with different secret
|
|
246
|
+
with pytest.raises(HTTPException) as exc:
|
|
247
|
+
_run(src.handle(req))
|
|
248
|
+
assert exc.value.status_code == 400
|
|
249
|
+
|
|
250
|
+
def test_missing_secret_accepts_unverified(self):
|
|
251
|
+
"""No secret captured yet -> log warning, accept event."""
|
|
252
|
+
src = self._build_source(secret_value=None)
|
|
253
|
+
body = b'{"event":"payload"}'
|
|
254
|
+
|
|
255
|
+
class FakeReq:
|
|
256
|
+
headers = {}
|
|
257
|
+
|
|
258
|
+
async def body(self):
|
|
259
|
+
return body
|
|
260
|
+
|
|
261
|
+
with patch("services.event_waiter.dispatch") as dispatch:
|
|
262
|
+
ev = _run(src.handle(FakeReq()))
|
|
263
|
+
assert ev.type == "fake.event"
|
|
264
|
+
dispatch.assert_called_once()
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
# ============================================================================
|
|
268
|
+
# DaemonEventSource lifecycle (mocked ProcessService)
|
|
269
|
+
# ============================================================================
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
class TestDaemonEventSource:
|
|
273
|
+
def test_start_calls_process_service_start(self):
|
|
274
|
+
from services.events import DaemonEventSource
|
|
275
|
+
|
|
276
|
+
class FakeDaemon(DaemonEventSource):
|
|
277
|
+
type = "fake.daemon"
|
|
278
|
+
process_name = "fake-daemon"
|
|
279
|
+
binary_name = "echo" # always on PATH
|
|
280
|
+
|
|
281
|
+
def build_command(self, secrets):
|
|
282
|
+
return "echo hello"
|
|
283
|
+
|
|
284
|
+
def parse_line(self, stream, line):
|
|
285
|
+
return None
|
|
286
|
+
|
|
287
|
+
async def go():
|
|
288
|
+
with patch("services.events.daemon.shutil.which", return_value="/usr/bin/echo"), \
|
|
289
|
+
patch("services.events.daemon.get_process_service") as get_ps:
|
|
290
|
+
ps = get_ps.return_value
|
|
291
|
+
|
|
292
|
+
async def fake_start(**kwargs):
|
|
293
|
+
return {"success": True, "result": {"pid": 4242}}
|
|
294
|
+
|
|
295
|
+
ps.start.side_effect = fake_start
|
|
296
|
+
|
|
297
|
+
async def fake_stop(**kwargs):
|
|
298
|
+
return {"success": True}
|
|
299
|
+
|
|
300
|
+
ps.stop.side_effect = fake_stop
|
|
301
|
+
|
|
302
|
+
src = FakeDaemon()
|
|
303
|
+
result = await src.start()
|
|
304
|
+
assert result["success"] is True
|
|
305
|
+
assert src.pid == 4242
|
|
306
|
+
ps.start.assert_called_once()
|
|
307
|
+
# Cancel the tail task so the test exits cleanly.
|
|
308
|
+
await src.stop()
|
|
309
|
+
assert src.pid is None
|
|
310
|
+
|
|
311
|
+
_run(go())
|
|
312
|
+
|
|
313
|
+
def test_start_fails_when_binary_missing(self):
|
|
314
|
+
from services.events import DaemonEventSource
|
|
315
|
+
|
|
316
|
+
class FakeDaemon(DaemonEventSource):
|
|
317
|
+
type = "fake.daemon"
|
|
318
|
+
process_name = "fake-daemon"
|
|
319
|
+
binary_name = "definitely-not-on-path-zzzzz"
|
|
320
|
+
install_hint = "see docs"
|
|
321
|
+
|
|
322
|
+
def build_command(self, secrets):
|
|
323
|
+
return "x"
|
|
324
|
+
|
|
325
|
+
async def go():
|
|
326
|
+
with patch("services.events.daemon.shutil.which", return_value=None):
|
|
327
|
+
src = FakeDaemon()
|
|
328
|
+
result = await src.start()
|
|
329
|
+
assert result["success"] is False
|
|
330
|
+
assert "PATH" in result["error"]
|
|
331
|
+
assert "see docs" in result["error"]
|
|
332
|
+
|
|
333
|
+
_run(go())
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
"""Wave 6 NodeSpec contract tests.
|
|
2
2
|
|
|
3
3
|
Locks in the public shape emitted by services/node_input_schemas.py,
|
|
4
|
-
services/node_spec.py, services/
|
|
5
|
-
/api/schemas/nodes/*/spec.json +
|
|
6
|
-
Mirrors the Wave 3 test posture
|
|
4
|
+
services/node_spec.py, services/ws_handler_registry (option-loader
|
|
5
|
+
registry, post-Wave-11.I M.3), and the /api/schemas/nodes/*/spec.json +
|
|
6
|
+
/api/schemas/nodes/options/* endpoints. Mirrors the Wave 3 test posture
|
|
7
|
+
for node_output_schemas.
|
|
7
8
|
"""
|
|
8
9
|
|
|
9
10
|
import pytest # noqa: F401 (used by @pytest.mark.asyncio on Phase 4 tests)
|
|
@@ -16,6 +17,19 @@ from services.node_input_schemas import (
|
|
|
16
17
|
from services.node_spec import get_node_spec, list_node_types_with_spec
|
|
17
18
|
|
|
18
19
|
|
|
20
|
+
def _numeric_constraints(prop: dict) -> dict:
|
|
21
|
+
"""Pydantic emits ``Optional[float] = Field(ge=..., le=...)`` as
|
|
22
|
+
``{anyOf: [{number, minimum, maximum}, {null}]}``. This helper digs
|
|
23
|
+
out the numeric branch so tests don't have to special-case nullable
|
|
24
|
+
vs. required fields each time we tweak Params."""
|
|
25
|
+
if "minimum" in prop or "maximum" in prop:
|
|
26
|
+
return prop
|
|
27
|
+
for branch in prop.get("anyOf", ()):
|
|
28
|
+
if branch.get("type") == "number" or "minimum" in branch or "maximum" in branch:
|
|
29
|
+
return branch
|
|
30
|
+
return prop
|
|
31
|
+
|
|
32
|
+
|
|
19
33
|
class TestInputSchemas:
|
|
20
34
|
def test_registry_not_empty(self):
|
|
21
35
|
assert len(NODE_INPUT_MODELS) > 50
|
|
@@ -35,8 +49,9 @@ class TestInputSchemas:
|
|
|
35
49
|
schema = get_node_input_schema("aiAgent")
|
|
36
50
|
assert schema is not None
|
|
37
51
|
props = schema["properties"]
|
|
38
|
-
|
|
39
|
-
assert
|
|
52
|
+
temp = _numeric_constraints(props["temperature"])
|
|
53
|
+
assert temp["minimum"] == 0.0
|
|
54
|
+
assert temp["maximum"] == 2.0
|
|
40
55
|
# snake_case keys per Wave 11.E.4+ convention.
|
|
41
56
|
# NB: ``api_key`` is NOT a declared field — credentials live in
|
|
42
57
|
# the credentials DB and are auto-injected at execution time.
|
|
@@ -237,7 +252,7 @@ class TestPhase3cCoverage:
|
|
|
237
252
|
# All 16 specialized agents share SpecializedAgentParams - constraints
|
|
238
253
|
# like temperature 0-2 should appear identically.
|
|
239
254
|
spec = get_node_spec("coding_agent")
|
|
240
|
-
temp = spec["inputs"]["properties"]["temperature"]
|
|
255
|
+
temp = _numeric_constraints(spec["inputs"]["properties"]["temperature"])
|
|
241
256
|
assert temp["minimum"] == 0.0
|
|
242
257
|
assert temp["maximum"] == 2.0
|
|
243
258
|
|
|
@@ -372,31 +387,33 @@ class TestPhase4LoadOptions:
|
|
|
372
387
|
"""Wave 6 Phase 4: unified loadOptionsMethod dispatch registry."""
|
|
373
388
|
|
|
374
389
|
def test_registry_has_whatsapp_methods(self):
|
|
375
|
-
from services.
|
|
390
|
+
from services.ws_handler_registry import list_load_options_methods
|
|
391
|
+
methods = list_load_options_methods()
|
|
376
392
|
for method in ["whatsappGroups", "whatsappChannels", "whatsappGroupMembers"]:
|
|
377
|
-
assert method in
|
|
393
|
+
assert method in methods
|
|
378
394
|
|
|
379
395
|
def test_registry_has_google_methods(self):
|
|
380
|
-
from services.
|
|
396
|
+
from services.ws_handler_registry import list_load_options_methods
|
|
397
|
+
methods = list_load_options_methods()
|
|
381
398
|
for method in ["gmailLabels", "googleCalendarList", "googleDriveFolders", "googleTasklists"]:
|
|
382
|
-
assert method in
|
|
399
|
+
assert method in methods
|
|
383
400
|
|
|
384
401
|
def test_list_methods_sorted(self):
|
|
385
|
-
from services.
|
|
402
|
+
from services.ws_handler_registry import list_load_options_methods
|
|
386
403
|
methods = list_load_options_methods()
|
|
387
404
|
assert methods == sorted(methods)
|
|
388
405
|
assert len(methods) >= 7
|
|
389
406
|
|
|
390
407
|
@pytest.mark.asyncio
|
|
391
408
|
async def test_unknown_method_returns_empty(self):
|
|
392
|
-
from services.
|
|
409
|
+
from services.ws_handler_registry import dispatch_load_options
|
|
393
410
|
result = await dispatch_load_options("nonExistentMethodXyz", {})
|
|
394
411
|
assert result == []
|
|
395
412
|
|
|
396
413
|
@pytest.mark.asyncio
|
|
397
414
|
async def test_dispatch_passes_params(self):
|
|
398
415
|
# Smoke test: unknown method tolerates arbitrary params, doesn't crash
|
|
399
|
-
from services.
|
|
416
|
+
from services.ws_handler_registry import dispatch_load_options
|
|
400
417
|
result = await dispatch_load_options("unknown", {"group_id": "abc"})
|
|
401
418
|
assert result == []
|
|
402
419
|
|
|
@@ -730,17 +747,19 @@ class TestNodeSpecContractInvariants:
|
|
|
730
747
|
|
|
731
748
|
def test_load_options_methods_are_registered(self):
|
|
732
749
|
"""Every Pydantic Field(loadOptionsMethod=X) must point at a
|
|
733
|
-
|
|
750
|
+
registered loader -- either in the legacy table or in a
|
|
751
|
+
plugin's ``register_option_loader`` call. Catches typos and
|
|
734
752
|
forgotten loader registrations."""
|
|
735
753
|
from services.node_input_schemas import get_node_input_schema, NODE_INPUT_MODELS
|
|
736
|
-
from services.
|
|
754
|
+
from services.ws_handler_registry import list_load_options_methods
|
|
755
|
+
methods = set(list_load_options_methods())
|
|
737
756
|
for t in NODE_INPUT_MODELS:
|
|
738
757
|
schema = get_node_input_schema(t)
|
|
739
758
|
for prop_name, prop in schema.get("properties", {}).items():
|
|
740
759
|
method = prop.get("loadOptionsMethod")
|
|
741
760
|
if method is None:
|
|
742
761
|
continue
|
|
743
|
-
assert method in
|
|
762
|
+
assert method in methods, (
|
|
744
763
|
f"{t}.{prop_name} references unknown loadOptionsMethod {method!r}"
|
|
745
764
|
)
|
|
746
765
|
|
|
@@ -777,6 +796,11 @@ class TestNodeSpecContractInvariants:
|
|
|
777
796
|
"width", "height",
|
|
778
797
|
# Wave 10.G.5: start-node's user-authored JSON blob marker
|
|
779
798
|
"hasInitialDataBlob",
|
|
799
|
+
# Auto-derived from group membership ('memory' / 'tool') by
|
|
800
|
+
# _derive_auto_ui_hints in services/plugin/base.py. Tells the
|
|
801
|
+
# parameter panel that this node is an auxiliary config node
|
|
802
|
+
# and should inherit the parent's main inputs.
|
|
803
|
+
"isConfigNode",
|
|
780
804
|
}
|
|
781
805
|
for node_type, meta in NODE_METADATA.items():
|
|
782
806
|
hints = meta.get("uiHints") or {}
|
|
@@ -882,10 +906,26 @@ class TestPluginContractInvariants:
|
|
|
882
906
|
def test_hide_output_handle_nodes_really_have_no_output(self):
|
|
883
907
|
# If a node advertises hideOutputHandle=True, it shouldn't also
|
|
884
908
|
# declare an output in its handles list — inconsistent.
|
|
909
|
+
# Exception: tool-oriented nodes (component_kind="tool" pure
|
|
910
|
+
# ToolNodes, or ActionNode + usable_as_tool=True dual-use nodes)
|
|
911
|
+
# have hide_output_handle auto-derived True via
|
|
912
|
+
# BaseNode.__init_subclass__. Their declared handles tuple still
|
|
913
|
+
# carries output-tool / output-main for backend awareness, but
|
|
914
|
+
# the SquareNode frontend suppresses the default render. The
|
|
915
|
+
# invariant's original intent was "explicit author intent should
|
|
916
|
+
# be consistent" — auto-derived is not author intent, so skip
|
|
917
|
+
# those nodes.
|
|
918
|
+
from services.node_registry import get_node_class
|
|
885
919
|
for t in self._plugin_types():
|
|
886
920
|
spec = get_node_spec(t)
|
|
887
921
|
if not spec.get("hideOutputHandle"):
|
|
888
922
|
continue
|
|
923
|
+
cls = get_node_class(t)
|
|
924
|
+
if cls is not None and (
|
|
925
|
+
getattr(cls, "usable_as_tool", False)
|
|
926
|
+
or getattr(cls, "component_kind", "") == "tool"
|
|
927
|
+
):
|
|
928
|
+
continue
|
|
889
929
|
outs = [h for h in spec.get("handles") or [] if h.get("kind") == "output"]
|
|
890
930
|
assert not outs, (
|
|
891
931
|
f"{t}: hideOutputHandle=True but handles declares output(s) {outs}"
|