onbuzz 4.9.13 → 4.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/node_modules/glob/README.md +31 -5
- package/node_modules/glob/dist/commonjs/glob.d.ts +8 -0
- package/node_modules/glob/dist/commonjs/glob.d.ts.map +1 -1
- package/node_modules/glob/dist/commonjs/glob.js +2 -1
- package/node_modules/glob/dist/commonjs/glob.js.map +1 -1
- package/node_modules/glob/dist/commonjs/index.min.js +3 -3
- package/node_modules/glob/dist/commonjs/index.min.js.map +4 -4
- package/node_modules/glob/dist/commonjs/pattern.d.ts +3 -0
- package/node_modules/glob/dist/commonjs/pattern.d.ts.map +1 -1
- package/node_modules/glob/dist/commonjs/pattern.js +4 -0
- package/node_modules/glob/dist/commonjs/pattern.js.map +1 -1
- package/node_modules/glob/dist/esm/glob.d.ts +8 -0
- package/node_modules/glob/dist/esm/glob.d.ts.map +1 -1
- package/node_modules/glob/dist/esm/glob.js +2 -1
- package/node_modules/glob/dist/esm/glob.js.map +1 -1
- package/node_modules/glob/dist/esm/index.min.js +3 -3
- package/node_modules/glob/dist/esm/index.min.js.map +4 -4
- package/node_modules/glob/dist/esm/pattern.d.ts +3 -0
- package/node_modules/glob/dist/esm/pattern.d.ts.map +1 -1
- package/node_modules/glob/dist/esm/pattern.js +4 -0
- package/node_modules/glob/dist/esm/pattern.js.map +1 -1
- package/node_modules/{@isaacs → glob/node_modules}/balanced-match/README.md +7 -10
- package/node_modules/{@isaacs → glob/node_modules}/balanced-match/package.json +7 -18
- package/node_modules/{@isaacs → glob/node_modules}/brace-expansion/README.md +3 -6
- package/node_modules/{@isaacs → glob/node_modules}/brace-expansion/dist/commonjs/index.js +6 -4
- package/node_modules/glob/node_modules/brace-expansion/dist/commonjs/index.js.map +1 -0
- package/node_modules/{@isaacs → glob/node_modules}/brace-expansion/dist/esm/index.js +6 -4
- package/node_modules/glob/node_modules/brace-expansion/dist/esm/index.js.map +1 -0
- package/node_modules/{@isaacs → glob/node_modules}/brace-expansion/package.json +11 -7
- package/node_modules/glob/node_modules/minimatch/README.md +76 -1
- package/node_modules/glob/node_modules/minimatch/dist/commonjs/assert-valid-pattern.d.ts +1 -1
- package/node_modules/glob/node_modules/minimatch/dist/commonjs/assert-valid-pattern.d.ts.map +1 -1
- package/node_modules/glob/node_modules/minimatch/dist/commonjs/assert-valid-pattern.js.map +1 -1
- package/node_modules/glob/node_modules/minimatch/dist/commonjs/ast.d.ts +4 -2
- package/node_modules/glob/node_modules/minimatch/dist/commonjs/ast.d.ts.map +1 -1
- package/node_modules/glob/node_modules/minimatch/dist/commonjs/ast.js +309 -55
- package/node_modules/glob/node_modules/minimatch/dist/commonjs/ast.js.map +1 -1
- package/node_modules/glob/node_modules/minimatch/dist/commonjs/brace-expressions.d.ts.map +1 -1
- package/node_modules/glob/node_modules/minimatch/dist/commonjs/brace-expressions.js +2 -4
- package/node_modules/glob/node_modules/minimatch/dist/commonjs/brace-expressions.js.map +1 -1
- package/node_modules/glob/node_modules/minimatch/dist/commonjs/escape.d.ts +1 -1
- package/node_modules/glob/node_modules/minimatch/dist/commonjs/escape.d.ts.map +1 -1
- package/node_modules/glob/node_modules/minimatch/dist/commonjs/escape.js +4 -4
- package/node_modules/glob/node_modules/minimatch/dist/commonjs/escape.js.map +1 -1
- package/node_modules/glob/node_modules/minimatch/dist/commonjs/index.d.ts +81 -1
- package/node_modules/glob/node_modules/minimatch/dist/commonjs/index.d.ts.map +1 -1
- package/node_modules/glob/node_modules/minimatch/dist/commonjs/index.js +232 -134
- package/node_modules/glob/node_modules/minimatch/dist/commonjs/index.js.map +1 -1
- package/node_modules/glob/node_modules/minimatch/dist/commonjs/unescape.d.ts +1 -1
- package/node_modules/glob/node_modules/minimatch/dist/commonjs/unescape.d.ts.map +1 -1
- package/node_modules/glob/node_modules/minimatch/dist/commonjs/unescape.js +8 -8
- package/node_modules/glob/node_modules/minimatch/dist/commonjs/unescape.js.map +1 -1
- package/node_modules/glob/node_modules/minimatch/dist/esm/assert-valid-pattern.d.ts +1 -1
- package/node_modules/glob/node_modules/minimatch/dist/esm/assert-valid-pattern.d.ts.map +1 -1
- package/node_modules/glob/node_modules/minimatch/dist/esm/assert-valid-pattern.js.map +1 -1
- package/node_modules/glob/node_modules/minimatch/dist/esm/ast.d.ts +4 -2
- package/node_modules/glob/node_modules/minimatch/dist/esm/ast.d.ts.map +1 -1
- package/node_modules/glob/node_modules/minimatch/dist/esm/ast.js +309 -55
- package/node_modules/glob/node_modules/minimatch/dist/esm/ast.js.map +1 -1
- package/node_modules/glob/node_modules/minimatch/dist/esm/brace-expressions.d.ts.map +1 -1
- package/node_modules/glob/node_modules/minimatch/dist/esm/brace-expressions.js +2 -4
- package/node_modules/glob/node_modules/minimatch/dist/esm/brace-expressions.js.map +1 -1
- package/node_modules/glob/node_modules/minimatch/dist/esm/escape.d.ts +1 -1
- package/node_modules/glob/node_modules/minimatch/dist/esm/escape.d.ts.map +1 -1
- package/node_modules/glob/node_modules/minimatch/dist/esm/escape.js +4 -4
- package/node_modules/glob/node_modules/minimatch/dist/esm/escape.js.map +1 -1
- package/node_modules/glob/node_modules/minimatch/dist/esm/index.d.ts +81 -1
- package/node_modules/glob/node_modules/minimatch/dist/esm/index.d.ts.map +1 -1
- package/node_modules/glob/node_modules/minimatch/dist/esm/index.js +232 -134
- package/node_modules/glob/node_modules/minimatch/dist/esm/index.js.map +1 -1
- package/node_modules/glob/node_modules/minimatch/dist/esm/unescape.d.ts +1 -1
- package/node_modules/glob/node_modules/minimatch/dist/esm/unescape.d.ts.map +1 -1
- package/node_modules/glob/node_modules/minimatch/dist/esm/unescape.js +8 -8
- package/node_modules/glob/node_modules/minimatch/dist/esm/unescape.js.map +1 -1
- package/node_modules/glob/node_modules/minimatch/package.json +17 -11
- package/node_modules/glob/package.json +10 -13
- package/node_modules/minipass/LICENSE.md +55 -0
- package/node_modules/minipass/dist/commonjs/index.d.ts +12 -16
- package/node_modules/minipass/dist/commonjs/index.d.ts.map +1 -1
- package/node_modules/minipass/dist/commonjs/index.js +13 -3
- package/node_modules/minipass/dist/commonjs/index.js.map +1 -1
- package/node_modules/minipass/dist/esm/index.d.ts +12 -16
- package/node_modules/minipass/dist/esm/index.d.ts.map +1 -1
- package/node_modules/minipass/dist/esm/index.js +3 -1
- package/node_modules/minipass/dist/esm/index.js.map +1 -1
- package/node_modules/minipass/package.json +9 -14
- package/node_modules/path-scurry/node_modules/lru-cache/README.md +96 -10
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/browser/diagnostics-channel-browser.d.ts.map +1 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/browser/diagnostics-channel-browser.js.map +1 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/browser/diagnostics-channel.d.ts +5 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/browser/diagnostics-channel.js +7 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/browser/index.d.ts +1400 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/browser/index.d.ts.map +1 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/browser/index.js +1726 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/browser/index.js.map +1 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/browser/index.min.js +2 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/browser/index.min.js.map +7 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/browser/perf.d.ts +12 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/browser/perf.d.ts.map +1 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/browser/perf.js +10 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/browser/perf.js.map +1 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/diagnostics-channel-cjs.cjs.map +1 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/diagnostics-channel-cjs.d.cts.map +1 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/diagnostics-channel.d.ts +5 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/diagnostics-channel.js +7 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/index.d.ts +109 -32
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/index.d.ts.map +1 -1
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/index.js +334 -197
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/index.js.map +1 -1
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/index.min.js +1 -1
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/index.min.js.map +4 -4
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/node/diagnostics-channel-node.d.ts.map +1 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/node/diagnostics-channel-node.js.map +1 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/node/diagnostics-channel.d.ts +5 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/node/diagnostics-channel.js +9 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/node/index.d.ts +1400 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/node/index.d.ts.map +1 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/node/index.js +1726 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/node/index.js.map +1 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/node/index.min.js +2 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/node/index.min.js.map +7 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/node/perf.d.ts +12 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/node/perf.d.ts.map +1 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/node/perf.js +10 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/node/perf.js.map +1 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/perf.d.ts +12 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/perf.d.ts.map +1 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/perf.js +10 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/perf.js.map +1 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/browser/diagnostics-channel-browser.d.ts.map +1 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/browser/diagnostics-channel-browser.js.map +1 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/browser/diagnostics-channel.d.ts +5 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/browser/diagnostics-channel.js +4 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/browser/index.d.ts +1400 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/browser/index.d.ts.map +1 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/browser/index.js +1722 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/browser/index.js.map +1 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/browser/index.min.js +2 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/browser/index.min.js.map +7 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/browser/perf.d.ts +12 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/browser/perf.d.ts.map +1 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/browser/perf.js +7 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/browser/perf.js.map +1 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/diagnostics-channel-esm.d.mts.map +1 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/diagnostics-channel-esm.mjs.map +1 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/diagnostics-channel.d.ts +5 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/diagnostics-channel.js +19 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/index.d.ts +109 -32
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/index.d.ts.map +1 -1
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/index.js +333 -196
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/index.js.map +1 -1
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/index.min.js +1 -1
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/index.min.js.map +4 -4
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/node/diagnostics-channel-node.d.ts.map +1 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/node/diagnostics-channel-node.js.map +1 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/node/diagnostics-channel.d.ts +5 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/node/diagnostics-channel.js +6 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/node/index.d.ts +1400 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/node/index.d.ts.map +1 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/node/index.js +1722 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/node/index.js.map +1 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/node/index.min.js +2 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/node/index.min.js.map +7 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/node/perf.d.ts +12 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/node/perf.d.ts.map +1 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/node/perf.js +7 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/node/perf.js.map +1 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/perf.d.ts +12 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/perf.d.ts.map +1 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/perf.js +7 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/perf.js.map +1 -0
- package/node_modules/path-scurry/node_modules/lru-cache/package.json +71 -18
- package/node_modules/path-scurry/package.json +8 -24
- package/package.json +1 -1
- package/scripts/debug-balance-probe.mjs +35 -35
- package/scripts/push-image.sh +43 -43
- package/scripts/setup-acr.sh +65 -65
- package/scripts/verify-optional-deps.js +96 -1
- package/src/__tests__/composioCliFlags.test.js +239 -239
- package/src/analyzers/CSSAnalyzer.js +298 -297
- package/src/analyzers/ConfigValidator.js +691 -690
- package/src/analyzers/ESLintAnalyzer.js +320 -320
- package/src/analyzers/JavaScriptAnalyzer.js +260 -261
- package/src/analyzers/PrettierFormatter.js +246 -247
- package/src/analyzers/PythonAnalyzer.js +283 -283
- package/src/analyzers/SecurityAnalyzer.js +729 -729
- package/src/analyzers/SparrowAnalyzer.js +341 -341
- package/src/analyzers/TypeScriptAnalyzer.js +247 -247
- package/src/analyzers/__tests__/CSSAnalyzer.test.js +41 -41
- package/src/analyzers/__tests__/ConfigValidator.test.js +362 -362
- package/src/analyzers/__tests__/JavaScriptAnalyzer.test.js +40 -40
- package/src/analyzers/__tests__/PythonAnalyzer.test.js +205 -208
- package/src/analyzers/__tests__/SecurityAnalyzer.test.js +303 -303
- package/src/analyzers/__tests__/TypeScriptAnalyzer.test.js +187 -187
- package/src/analyzers/codeCloneDetector/analyzer.js +344 -344
- package/src/analyzers/codeCloneDetector/detector.js +250 -250
- package/src/analyzers/codeCloneDetector/index.js +194 -192
- package/src/analyzers/codeCloneDetector/parser.js +199 -199
- package/src/core/__tests__/agentPool.test.js +866 -866
- package/src/core/__tests__/agentPoolAutoResume.test.js +209 -209
- package/src/core/__tests__/agentPoolWakeOnMessage.test.js +315 -315
- package/src/core/__tests__/agentScheduler.emptyResponseChatStall.test.js +213 -213
- package/src/core/__tests__/agentScheduler.errorCategorisation.test.js +246 -246
- package/src/core/__tests__/agentScheduler.firstChunkTimeout.test.js +138 -138
- package/src/core/__tests__/agentScheduler.modeTransitions.test.js +233 -233
- package/src/core/__tests__/agentScheduler.nativePromptPick.test.js +319 -319
- package/src/core/__tests__/agentScheduler.taskLifecycleInstruction.test.js +78 -78
- package/src/core/__tests__/agentScheduler.visualizer.test.js +258 -258
- package/src/core/__tests__/flowCheckpointStore.test.js +140 -140
- package/src/core/__tests__/flowEndToEnd.test.js +565 -565
- package/src/core/__tests__/flowFieldMapping.test.js +188 -189
- package/src/core/__tests__/flowLintClientMirror.test.js +96 -98
- package/src/core/__tests__/flowSavePayload.test.js +170 -169
- package/src/core/__tests__/flowTemplates.test.js +311 -311
- package/src/core/__tests__/flowVersionStore.test.js +123 -123
- package/src/core/__tests__/messageProcessor.test.js +669 -669
- package/src/core/__tests__/stateManager.test.js +0 -1
- package/src/core/agentPool.js +2474 -2475
- package/src/core/agentScheduler.js +1 -4
- package/src/core/contextManager.js +708 -708
- package/src/core/flowExecutor.js +1510 -1510
- package/src/core/flowFieldMapping.js +136 -138
- package/src/core/messageProcessor.js +953 -954
- package/src/core/orchestrator.js +593 -595
- package/src/core/stateManager.js +1765 -1752
- package/src/index.js +1221 -1221
- package/src/interfaces/__tests__/archivedAgentDelete.test.js +207 -207
- package/src/interfaces/__tests__/bulkAgentRoute.test.js +361 -361
- package/src/interfaces/__tests__/imageServing.test.js +228 -228
- package/src/interfaces/__tests__/remoteSessionAuth.test.js +308 -308
- package/src/interfaces/__tests__/videoJobsRoutes.test.js +178 -179
- package/src/interfaces/__tests__/webServer.marketplace.test.js +629 -629
- package/src/interfaces/schedulerRoutes.js +50 -50
- package/src/interfaces/terminal/__tests__/smoke/connection.test.js +341 -350
- package/src/interfaces/terminal/__tests__/smoke/enhancements.test.js +156 -156
- package/src/interfaces/terminal/__tests__/smoke/imports.test.js +325 -330
- package/src/interfaces/terminal/__tests__/smoke/tools.test.js +385 -388
- package/src/interfaces/terminal/api/session.js +265 -266
- package/src/interfaces/terminal/api/websocket.js +496 -497
- package/src/interfaces/terminal/components/AgentCreator.js +691 -705
- package/src/interfaces/terminal/components/AgentEditor.js +676 -678
- package/src/interfaces/terminal/components/AgentSwitcher.js +331 -330
- package/src/interfaces/terminal/components/ErrorPanel.js +263 -264
- package/src/interfaces/terminal/components/Header.js +28 -28
- package/src/interfaces/terminal/components/Layout.js +598 -603
- package/src/interfaces/terminal/components/MessageList.js +280 -281
- package/src/interfaces/terminal/components/SettingsPanel.js +410 -415
- package/src/interfaces/terminal/components/StatusBar.js +2 -0
- package/src/interfaces/terminal/index.js +168 -168
- package/src/interfaces/terminal/state/useAgentControl.js +496 -496
- package/src/interfaces/terminal/state/useAgents.js +537 -537
- package/src/interfaces/terminal/state/useMessages.js +629 -630
- package/src/interfaces/terminal/state/useTools.js +554 -554
- package/src/interfaces/terminal/utils/debugLogger.js +44 -44
- package/src/interfaces/terminal/utils/settingsStorage.js +232 -232
- package/src/interfaces/webServer.js +7578 -7579
- package/src/interfaces/webServer.js.bak +7046 -7046
- package/src/modules/fileExplorer/__tests__/zipDownload.test.js +237 -237
- package/src/modules/fileExplorer/controller.js +470 -469
- package/src/modules/fileExplorer/routes.js +285 -286
- package/src/modules/widget/__tests__/isDisabled.test.js +41 -41
- package/src/modules/widget/__tests__/routes.test.js +677 -678
- package/src/modules/widget/__tests__/runtime.test.js +401 -401
- package/src/modules/widget/__tests__/versioning.test.js +309 -309
- package/src/modules/widget/__tests__/webComponentRuntime.test.js +565 -565
- package/src/modules/widget/__tests__/widgetTool.test.js +316 -316
- package/src/modules/widget/routes.js +435 -435
- package/src/modules/widget/runtime/bundle.js +640 -640
- package/src/modules/widget/runtime/webComponentBundle.js +470 -470
- package/src/modules/widget/schema.js +182 -181
- package/src/modules/widget/widgetTool.js +1389 -1389
- package/src/services/__tests__/agentActivityService.test.js +401 -402
- package/src/services/__tests__/benchmarkService.test.js +184 -184
- package/src/services/__tests__/contextInjectionService.test.js +246 -246
- package/src/services/__tests__/conversationQuery.test.js +721 -723
- package/src/services/__tests__/credentialVault.test.js +469 -469
- package/src/services/__tests__/discordService.integration.test.js +638 -639
- package/src/services/__tests__/flowContextService.test.js +590 -590
- package/src/services/__tests__/memoryService.test.js +1 -1
- package/src/services/__tests__/messageSource.test.js +380 -380
- package/src/services/__tests__/modelRouterNaming.test.js +111 -111
- package/src/services/__tests__/projectDetector.test.js +34 -34
- package/src/services/__tests__/promptService.test.js +242 -242
- package/src/services/__tests__/telegramService.test.js +941 -941
- package/src/services/__tests__/tokenCountingService.test.js +48 -48
- package/src/services/agentActivityService.js +419 -420
- package/src/services/aiService.js +2997 -3001
- package/src/services/apiKeyManager.js +359 -359
- package/src/services/benchmarkService.js +196 -196
- package/src/services/codebaseKnowledgeService.js +2 -2
- package/src/services/composioService.js +738 -738
- package/src/services/conversationCompactionService.js +1258 -1257
- package/src/services/credentialVault.js +685 -685
- package/src/services/discordService.js +792 -793
- package/src/services/embeddings/__tests__/azureCustomProvider.test.js +232 -232
- package/src/services/embeddings/__tests__/embeddingService.test.js +417 -417
- package/src/services/embeddings/__tests__/localProvider.test.js +263 -263
- package/src/services/embeddings/autoRecall.js +218 -219
- package/src/services/embeddings/indexers/__tests__/agentIndexer.test.js +232 -232
- package/src/services/embeddings/indexers/__tests__/memoryIndexer.test.js +418 -418
- package/src/services/embeddings/indexers/__tests__/reminisceIndexer.test.js +356 -357
- package/src/services/embeddings/indexers/__tests__/skillsIndexer.test.js +145 -145
- package/src/services/embeddings/indexers/__tests__/taskIndexer.test.js +146 -146
- package/src/services/embeddings/indexers/composioIndexer.js +279 -279
- package/src/services/embeddings/providerInterface.js +206 -206
- package/src/services/embeddings/providers/localProvider.js +11 -7
- package/src/services/embeddings/providers/openaiProvider.js +101 -101
- package/src/services/embeddings/vectorStore/inMemoryJsonStore.js +356 -356
- package/src/services/errorHandler.js +809 -809
- package/src/services/flowContextService.js +586 -586
- package/src/services/grounding/MockAdapter.js +125 -125
- package/src/services/modelRouterService.js +26 -31
- package/src/services/modelsService.js +322 -322
- package/src/services/ollamaService.js +452 -452
- package/src/services/projectDetector.js +403 -404
- package/src/services/promptService.js +418 -418
- package/src/services/qualityInspector.js +795 -795
- package/src/services/scheduleService.js +726 -726
- package/src/services/serviceRegistry.js +386 -386
- package/src/services/telegrafBot.js +174 -174
- package/src/services/telegramService.js +1972 -1972
- package/src/services/visualEditorBridge.js +1033 -1033
- package/src/services/visualEditorServer.js +1769 -1774
- package/src/services/whatsappService.js +667 -668
- package/src/tools/__tests__/agentCommunicationTool.findAgent.test.js +226 -226
- package/src/tools/__tests__/agentCommunicationTool.test.js +3 -3
- package/src/tools/__tests__/agentDelayTool.test.js +342 -342
- package/src/tools/__tests__/baseTool.test.js +3 -3
- package/src/tools/__tests__/codeMapTool.test.js +915 -915
- package/src/tools/__tests__/fileContentReplaceTool.test.js +309 -309
- package/src/tools/__tests__/fileTreeTool.test.js +274 -274
- package/src/tools/__tests__/filesystemTool.test.js +815 -815
- package/src/tools/__tests__/foundryWebSearchTool.test.js +252 -252
- package/src/tools/__tests__/imageTool.validator.test.js +194 -194
- package/src/tools/__tests__/jobDoneTool.test.js +580 -581
- package/src/tools/__tests__/memoryTool.forgetStale.test.js +272 -272
- package/src/tools/__tests__/memoryTool.reminisce.test.js +2 -2
- package/src/tools/__tests__/memoryTool.reminisceSemanticSearch.test.js +301 -301
- package/src/tools/__tests__/memoryTool.semanticSearch.test.js +405 -405
- package/src/tools/__tests__/memoryTool.teamPool.test.js +293 -293
- package/src/tools/__tests__/memoryTool.test.js +1 -1
- package/src/tools/__tests__/seekTool.test.js +282 -282
- package/src/tools/__tests__/skillsTool.search.test.js +164 -164
- package/src/tools/__tests__/skillsTool.test.js +226 -226
- package/src/tools/__tests__/staticAnalysisTool.test.js +509 -509
- package/src/tools/__tests__/taskManagerTool.discipline.test.js +137 -137
- package/src/tools/__tests__/taskManagerTool.search.test.js +143 -143
- package/src/tools/__tests__/taskManagerTool.test.js +866 -866
- package/src/tools/__tests__/terminalTool.test.js +448 -448
- package/src/tools/__tests__/toolShapeForgiveness.test.js +259 -260
- package/src/tools/__tests__/userPromptTool.test.js +297 -297
- package/src/tools/__tests__/videoTool.jobs.test.js +147 -147
- package/src/tools/__tests__/webTool.e2e.test.js +609 -603
- package/src/tools/__tests__/webTool.unit.test.js +195 -195
- package/src/tools/__tests__/webTool.visionModel.test.js +75 -75
- package/src/tools/agentCommunicationTool.js +8 -10
- package/src/tools/agentDelayTool.js +496 -497
- package/src/tools/asyncToolManager.js +602 -603
- package/src/tools/baseTool.js +12 -11
- package/src/tools/cloneDetectionTool.js +576 -581
- package/src/tools/codeMapTool.js +0 -6
- package/src/tools/composioTool.js +617 -617
- package/src/tools/dependencyResolverTool.js +1211 -1212
- package/src/tools/desktop/DesktopTool.js +629 -638
- package/src/tools/desktop/__tests__/DesktopTool.e2e.test.js +306 -306
- package/src/tools/desktop/__tests__/DesktopTool.test.js +507 -507
- package/src/tools/desktop/__tests__/osController.test.js +364 -364
- package/src/tools/desktop/osController.js +491 -491
- package/src/tools/docxTool.js +623 -623
- package/src/tools/excelTool.js +636 -636
- package/src/tools/fileContentReplaceTool.js +5 -7
- package/src/tools/fileSystemTool.js +12 -19
- package/src/tools/fileTreeTool.js +840 -840
- package/src/tools/foundryWebSearchTool.js +273 -273
- package/src/tools/helpTool.js +198 -198
- package/src/tools/imageTool.js +1397 -1397
- package/src/tools/importAnalyzerTool.js +1056 -1056
- package/src/tools/jobDoneTool.js +495 -495
- package/src/tools/memoryTool.js +1 -1
- package/src/tools/office/pres/__tests__/presSystem.test.js +365 -365
- package/src/tools/office/pres/archetypes/agenda.js +61 -61
- package/src/tools/office/pres/archetypes/bentoGrid.js +218 -219
- package/src/tools/office/pres/archetypes/bigStat.js +140 -142
- package/src/tools/office/pres/archetypes/closing.js +70 -70
- package/src/tools/office/pres/archetypes/hero.js +70 -70
- package/src/tools/office/pres/archetypes/productHero.js +93 -94
- package/src/tools/office/pres/archetypes/table.js +73 -74
- package/src/tools/office/pres/backgrounds/orb.js +66 -66
- package/src/tools/office/pres/components.js +422 -423
- package/src/tools/officeTool.js +441 -441
- package/src/tools/pdfTool.js +625 -627
- package/src/tools/platformControlTool.js +1081 -1081
- package/src/tools/seekTool.js +917 -918
- package/src/tools/skillsTool.js +1 -1
- package/src/tools/staticAnalysisTool.js +2143 -2146
- package/src/tools/taskManagerTool.js +3324 -3324
- package/src/tools/terminalTool.js +2615 -2618
- package/src/tools/videoTool.js +1303 -1303
- package/src/tools/visionTool.js +508 -508
- package/src/tools/visualEditorTool.js +1289 -1290
- package/src/tools/webTool.js +3368 -3368
- package/src/tools/whatsappTool.js +464 -464
- package/src/types/__tests__/agent.test.js +499 -499
- package/src/types/__tests__/contextReference.test.js +606 -606
- package/src/types/__tests__/conversation.test.js +555 -555
- package/src/types/__tests__/toolCommand.test.js +584 -584
- package/src/types/contextReference.js +974 -971
- package/src/types/conversation.js +729 -729
- package/src/types/toolCommand.js +746 -746
- package/src/utilities/__tests__/attachmentValidator.test.js +80 -80
- package/src/utilities/__tests__/auditReport.test.js +328 -328
- package/src/utilities/__tests__/directoryAccessManager.test.js +388 -388
- package/src/utilities/__tests__/jsonRepair.test.js +103 -104
- package/src/utilities/__tests__/modeTransitionReasons.test.js +105 -105
- package/src/utilities/__tests__/platformUtils.test.js +80 -87
- package/src/utilities/__tests__/structuredFileValidator.test.js +261 -263
- package/src/utilities/__tests__/toolConstants.test.js +92 -94
- package/src/utilities/__tests__/useIsTouchDevice.detect.test.js +114 -114
- package/src/utilities/__tests__/webUiUtilSync.test.js +117 -117
- package/src/utilities/attachmentValidator.js +284 -288
- package/src/utilities/authCache.js.backup-1779570472481 +121 -121
- package/src/utilities/browserStealth.js +631 -630
- package/src/utilities/configManager.js +616 -617
- package/src/utilities/directoryAccessManager.js +564 -565
- package/src/utilities/fileProcessor.js +308 -307
- package/src/utilities/humanBehavior.js +454 -453
- package/src/utilities/logger.js +479 -479
- package/src/utilities/structuredFileValidator.js +696 -699
- package/src/utilities/tagParser.js +5 -10
- package/src/utilities/userDataDir.js +308 -308
- package/node_modules/@isaacs/brace-expansion/dist/commonjs/index.js.map +0 -1
- package/node_modules/@isaacs/brace-expansion/dist/esm/index.js.map +0 -1
- package/node_modules/minipass/LICENSE +0 -15
- /package/node_modules/{@isaacs → glob/node_modules}/balanced-match/LICENSE.md +0 -0
- /package/node_modules/{@isaacs → glob/node_modules}/balanced-match/dist/commonjs/index.d.ts +0 -0
- /package/node_modules/{@isaacs → glob/node_modules}/balanced-match/dist/commonjs/index.d.ts.map +0 -0
- /package/node_modules/{@isaacs → glob/node_modules}/balanced-match/dist/commonjs/index.js +0 -0
- /package/node_modules/{@isaacs → glob/node_modules}/balanced-match/dist/commonjs/index.js.map +0 -0
- /package/node_modules/{@isaacs → glob/node_modules}/balanced-match/dist/commonjs/package.json +0 -0
- /package/node_modules/{@isaacs → glob/node_modules}/balanced-match/dist/esm/index.d.ts +0 -0
- /package/node_modules/{@isaacs → glob/node_modules}/balanced-match/dist/esm/index.d.ts.map +0 -0
- /package/node_modules/{@isaacs → glob/node_modules}/balanced-match/dist/esm/index.js +0 -0
- /package/node_modules/{@isaacs → glob/node_modules}/balanced-match/dist/esm/index.js.map +0 -0
- /package/node_modules/{@isaacs → glob/node_modules}/balanced-match/dist/esm/package.json +0 -0
- /package/node_modules/{@isaacs → glob/node_modules}/brace-expansion/LICENSE +0 -0
- /package/node_modules/{@isaacs → glob/node_modules}/brace-expansion/dist/commonjs/index.d.ts +0 -0
- /package/node_modules/{@isaacs → glob/node_modules}/brace-expansion/dist/commonjs/index.d.ts.map +0 -0
- /package/node_modules/{@isaacs → glob/node_modules}/brace-expansion/dist/commonjs/package.json +0 -0
- /package/node_modules/{@isaacs → glob/node_modules}/brace-expansion/dist/esm/index.d.ts +0 -0
- /package/node_modules/{@isaacs → glob/node_modules}/brace-expansion/dist/esm/index.d.ts.map +0 -0
- /package/node_modules/{@isaacs → glob/node_modules}/brace-expansion/dist/esm/package.json +0 -0
|
@@ -1,941 +1,941 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Tests for the rewritten TelegramService.
|
|
3
|
-
*
|
|
4
|
-
* Covers the user-experience-critical surfaces:
|
|
5
|
-
* • Multi-chat /start (no global lock)
|
|
6
|
-
* • Group chat @mention gating
|
|
7
|
-
* • Voice → backend transcribe → text dispatch
|
|
8
|
-
* • Thinking placeholder edit-on-completion
|
|
9
|
-
* • <actions> / <image> / <attachment> block dispatch
|
|
10
|
-
* • Always-relay error broadcasts vs watch-gated completions
|
|
11
|
-
* • /reset, /new, /logout commands
|
|
12
|
-
* • Pagination of /agents
|
|
13
|
-
* • Markdown vs HTML vs document selection by content shape
|
|
14
|
-
* • Config migration (legacy `chatId` scalar)
|
|
15
|
-
*
|
|
16
|
-
* The mock TelegramBot records every call and exposes them via
|
|
17
|
-
* `bot.calls.<method>` arrays. node-telegram-bot-api is fully mocked
|
|
18
|
-
* so no network and no dependency on the real bot library.
|
|
19
|
-
*/
|
|
20
|
-
|
|
21
|
-
import { jest, describe, test, expect, beforeEach } from '@jest/globals';
|
|
22
|
-
|
|
23
|
-
// ─── Module mocks ─────────────────────────────────────────────────────
|
|
24
|
-
|
|
25
|
-
// In-memory virtual fs for the config file. Each beforeEach resets it.
|
|
26
|
-
const virtualFs = { content: null };
|
|
27
|
-
|
|
28
|
-
jest.unstable_mockModule('fs', () => ({
|
|
29
|
-
promises: {
|
|
30
|
-
mkdir: jest.fn().mockResolvedValue(undefined),
|
|
31
|
-
readFile: jest.fn().mockImplementation(async () => {
|
|
32
|
-
if (virtualFs.content === null) throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
|
|
33
|
-
return virtualFs.content;
|
|
34
|
-
}),
|
|
35
|
-
writeFile: jest.fn().mockImplementation(async (_p, data) => {
|
|
36
|
-
virtualFs.content = data;
|
|
37
|
-
}),
|
|
38
|
-
},
|
|
39
|
-
}));
|
|
40
|
-
|
|
41
|
-
jest.unstable_mockModule('../../utilities/userDataDir.js', () => ({
|
|
42
|
-
getUserDataPaths: () => ({ base: '/mock/data' }),
|
|
43
|
-
ensureUserDataDirs: jest.fn().mockResolvedValue(undefined),
|
|
44
|
-
}));
|
|
45
|
-
|
|
46
|
-
// MockBot — a deterministic stand-in for node-telegram-bot-api. Records
|
|
47
|
-
// every call, exposes hooks so tests can simulate incoming messages.
|
|
48
|
-
class MockBot {
|
|
49
|
-
constructor() {
|
|
50
|
-
this._textHandlers = []; // [{regex, fn}]
|
|
51
|
-
this._messageHandlers = [];
|
|
52
|
-
this._callbackHandlers = [];
|
|
53
|
-
this.calls = {
|
|
54
|
-
sendMessage: [], sendPhoto: [], sendDocument: [],
|
|
55
|
-
sendChatAction: [], editMessageText: [], deleteMessage: [],
|
|
56
|
-
answerCallbackQuery: [],
|
|
57
|
-
};
|
|
58
|
-
this.nextMessageId = 100;
|
|
59
|
-
this.stopPolling = jest.fn().mockResolvedValue(undefined);
|
|
60
|
-
}
|
|
61
|
-
async getMe() { return { username: 'loxia_bot', id: 1 }; }
|
|
62
|
-
onText(regex, fn) { this._textHandlers.push({ regex, fn }); }
|
|
63
|
-
on(event, fn) {
|
|
64
|
-
if (event === 'message') this._messageHandlers.push(fn);
|
|
65
|
-
else if (event === 'callback_query') this._callbackHandlers.push(fn);
|
|
66
|
-
}
|
|
67
|
-
async sendMessage(chatId, text, opts) {
|
|
68
|
-
const id = this.nextMessageId++;
|
|
69
|
-
this.calls.sendMessage.push({ chatId, text, opts, message_id: id });
|
|
70
|
-
return { message_id: id, chat: { id: chatId } };
|
|
71
|
-
}
|
|
72
|
-
async editMessageText(text, opts) { this.calls.editMessageText.push({ text, opts }); return { ok: true }; }
|
|
73
|
-
async deleteMessage(chatId, message_id) { this.calls.deleteMessage.push({ chatId, message_id }); }
|
|
74
|
-
async sendChatAction(chatId, action) { this.calls.sendChatAction.push({ chatId, action }); }
|
|
75
|
-
async sendPhoto(chatId, photo, opts) { this.calls.sendPhoto.push({ chatId, photo, opts }); }
|
|
76
|
-
async sendDocument(chatId, doc, opts) { this.calls.sendDocument.push({ chatId, doc, opts }); }
|
|
77
|
-
async answerCallbackQuery(id) { this.calls.answerCallbackQuery.push(id); }
|
|
78
|
-
async getFileLink(fileId) { return `https://t.example/file/${fileId}`; }
|
|
79
|
-
|
|
80
|
-
// Test helpers: simulate an incoming message / callback.
|
|
81
|
-
async simulateMessage(msg) {
|
|
82
|
-
// Telegram fires onText first when text matches a regex, then `message` listeners.
|
|
83
|
-
if (typeof msg.text === 'string') {
|
|
84
|
-
for (const { regex, fn } of this._textHandlers) {
|
|
85
|
-
const m = msg.text.match(regex);
|
|
86
|
-
if (m) await fn(msg, m);
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
for (const fn of this._messageHandlers) await fn(msg);
|
|
90
|
-
}
|
|
91
|
-
async simulateCallback(query) {
|
|
92
|
-
for (const fn of this._callbackHandlers) await fn(query);
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
let currentBot;
|
|
97
|
-
// We migrated off node-telegram-bot-api to telegraf via a thin compat
|
|
98
|
-
// wrapper at src/services/telegrafBot.js. The MockBot above implements
|
|
99
|
-
// exactly the wrapper's surface (sendMessage, editMessageText,
|
|
100
|
-
// deleteMessage, sendChatAction, sendPhoto, sendDocument,
|
|
101
|
-
// answerCallbackQuery, getFileLink, getMe, stopPolling, onText, on) so
|
|
102
|
-
// retargeting the mock to the wrapper module is the entire test-side
|
|
103
|
-
// change. telegramService.js calls `createTelegrafBot(token, opts)` —
|
|
104
|
-
// match that shape (async factory returning the wrapper).
|
|
105
|
-
jest.unstable_mockModule('../telegrafBot.js', () => ({
|
|
106
|
-
createTelegrafBot: jest.fn(async () => {
|
|
107
|
-
currentBot = new MockBot();
|
|
108
|
-
return currentBot;
|
|
109
|
-
}),
|
|
110
|
-
default: jest.fn(async () => {
|
|
111
|
-
currentBot = new MockBot();
|
|
112
|
-
return currentBot;
|
|
113
|
-
}),
|
|
114
|
-
}));
|
|
115
|
-
|
|
116
|
-
// ─── Imports after mocks ──────────────────────────────────────────────
|
|
117
|
-
|
|
118
|
-
const { TelegramService,
|
|
119
|
-
|
|
120
|
-
// ─── Test helpers ─────────────────────────────────────────────────────
|
|
121
|
-
|
|
122
|
-
const SAMPLE_AGENTS = [
|
|
123
|
-
{ id: 'a1', name: 'Cody', mode: 'chat', status: 'idle', currentModel: 'gpt-4.1' },
|
|
124
|
-
{ id: 'a2', name: 'Lina', mode: 'auto', status: 'active', currentModel: 'claude-sonnet-4-5' },
|
|
125
|
-
];
|
|
126
|
-
|
|
127
|
-
function makeService({ agents = SAMPLE_AGENTS, withOrchestrator = true } = {}) {
|
|
128
|
-
const svc = new TelegramService(null);
|
|
129
|
-
svc.setAgentPool({
|
|
130
|
-
getAllAgents: jest.fn().mockResolvedValue(agents),
|
|
131
|
-
getAgent: jest.fn((id) => Promise.resolve(agents.find(a => a.id === id) || null)),
|
|
132
|
-
});
|
|
133
|
-
if (withOrchestrator) {
|
|
134
|
-
svc.setOrchestrator({
|
|
135
|
-
config: { project: { directory: '/proj' } },
|
|
136
|
-
processRequest: jest.fn().mockResolvedValue(undefined),
|
|
137
|
-
messageProcessor: {
|
|
138
|
-
stopAutonomousExecution: jest.fn().mockResolvedValue(undefined),
|
|
139
|
-
resetAgentConversation: jest.fn().mockResolvedValue(undefined),
|
|
140
|
-
},
|
|
141
|
-
stateManager: {
|
|
142
|
-
loadFlowIndex: jest.fn().mockResolvedValue({}),
|
|
143
|
-
},
|
|
144
|
-
});
|
|
145
|
-
}
|
|
146
|
-
return svc;
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
function userMsg(chatId, text, { chatType = 'private', from = { username: 'alice' }, title } = {}) {
|
|
150
|
-
return {
|
|
151
|
-
chat: { id: chatId, type: chatType, title },
|
|
152
|
-
from,
|
|
153
|
-
text,
|
|
154
|
-
message_id: 1,
|
|
155
|
-
};
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
beforeEach(() => {
|
|
159
|
-
virtualFs.content = null;
|
|
160
|
-
_resetTelegramSingletonForTests();
|
|
161
|
-
currentBot = null;
|
|
162
|
-
});
|
|
163
|
-
|
|
164
|
-
async function connected(svc) {
|
|
165
|
-
await svc.connect('TEST_TOKEN');
|
|
166
|
-
return currentBot;
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
// ─────────────────────────────────────────────────────────────────────
|
|
170
|
-
// @-prefix resolution (names with spaces, slug form, ambiguity)
|
|
171
|
-
// ─────────────────────────────────────────────────────────────────────
|
|
172
|
-
|
|
173
|
-
describe('_resolveAtPrefix — @agent-name parsing', () => {
|
|
174
|
-
const agents = [
|
|
175
|
-
{ id: 'a1', name: 'Cody' },
|
|
176
|
-
{ id: 'a2', name: 'John Smith' },
|
|
177
|
-
{ id: 'a3', name: 'John' }, // prefix-conflict with #a2
|
|
178
|
-
{ id: 'a4', name: 'Multi Word Long Name' },
|
|
179
|
-
];
|
|
180
|
-
|
|
181
|
-
test('no @prefix → agentName null, messageText unchanged', () => {
|
|
182
|
-
const svc = makeService({ agents });
|
|
183
|
-
const r = svc._resolveAtPrefix('just a message', agents);
|
|
184
|
-
expect(r.agentName).toBeNull();
|
|
185
|
-
expect(r.matchedAgent).toBeNull();
|
|
186
|
-
expect(r.messageText).toBe('just a message');
|
|
187
|
-
});
|
|
188
|
-
|
|
189
|
-
test('single-word name (legacy path)', () => {
|
|
190
|
-
const svc = makeService({ agents });
|
|
191
|
-
const r = svc._resolveAtPrefix('@Cody hello', agents);
|
|
192
|
-
expect(r.matchedAgent?.id).toBe('a1');
|
|
193
|
-
expect(r.messageText).toBe('hello');
|
|
194
|
-
});
|
|
195
|
-
|
|
196
|
-
test('multi-word name with literal spaces works', () => {
|
|
197
|
-
const svc = makeService({ agents });
|
|
198
|
-
const r = svc._resolveAtPrefix('@John Smith write the report', agents);
|
|
199
|
-
expect(r.matchedAgent?.id).toBe('a2');
|
|
200
|
-
expect(r.agentName).toBe('John Smith');
|
|
201
|
-
expect(r.messageText).toBe('write the report');
|
|
202
|
-
});
|
|
203
|
-
|
|
204
|
-
test('slug form @John_Smith resolves to "John Smith"', () => {
|
|
205
|
-
const svc = makeService({ agents });
|
|
206
|
-
const r = svc._resolveAtPrefix('@John_Smith write the report', agents);
|
|
207
|
-
expect(r.matchedAgent?.id).toBe('a2');
|
|
208
|
-
expect(r.messageText).toBe('write the report');
|
|
209
|
-
});
|
|
210
|
-
|
|
211
|
-
test('dash form @John-Smith also works', () => {
|
|
212
|
-
const svc = makeService({ agents });
|
|
213
|
-
const r = svc._resolveAtPrefix('@John-Smith hi', agents);
|
|
214
|
-
expect(r.matchedAgent?.id).toBe('a2');
|
|
215
|
-
expect(r.messageText).toBe('hi');
|
|
216
|
-
});
|
|
217
|
-
|
|
218
|
-
test('longest-match wins when one name is a prefix of another', () => {
|
|
219
|
-
// "@John Smith hello" must NOT resolve to the "John" agent.
|
|
220
|
-
const svc = makeService({ agents });
|
|
221
|
-
const r = svc._resolveAtPrefix('@John Smith hello', agents);
|
|
222
|
-
expect(r.matchedAgent?.id).toBe('a2');
|
|
223
|
-
});
|
|
224
|
-
|
|
225
|
-
test('shorter name still wins when the longer name doesn\'t match', () => {
|
|
226
|
-
const svc = makeService({ agents });
|
|
227
|
-
const r = svc._resolveAtPrefix('@John hello', agents);
|
|
228
|
-
expect(r.matchedAgent?.id).toBe('a3');
|
|
229
|
-
});
|
|
230
|
-
|
|
231
|
-
test('case-insensitive match', () => {
|
|
232
|
-
const svc = makeService({ agents });
|
|
233
|
-
const r = svc._resolveAtPrefix('@JOHN SMITH hi', agents);
|
|
234
|
-
expect(r.matchedAgent?.id).toBe('a2');
|
|
235
|
-
});
|
|
236
|
-
|
|
237
|
-
test('extra whitespace between the name and the message is collapsed', () => {
|
|
238
|
-
const svc = makeService({ agents });
|
|
239
|
-
const r = svc._resolveAtPrefix('@Cody \t hello', agents);
|
|
240
|
-
expect(r.messageText).toBe('hello');
|
|
241
|
-
});
|
|
242
|
-
|
|
243
|
-
test('partial-word collision does NOT false-match (Johnson should not match John)', () => {
|
|
244
|
-
const svc = makeService({ agents });
|
|
245
|
-
const r = svc._resolveAtPrefix('@Johnson hi', agents);
|
|
246
|
-
// Falls back to legacy capture so caller can show "not found"
|
|
247
|
-
expect(r.matchedAgent).toBeNull();
|
|
248
|
-
expect(r.agentName).toBe('Johnson');
|
|
249
|
-
});
|
|
250
|
-
|
|
251
|
-
test('unknown @name → legacy capture for "not found" error', () => {
|
|
252
|
-
const svc = makeService({ agents });
|
|
253
|
-
const r = svc._resolveAtPrefix('@Stranger hi', agents);
|
|
254
|
-
expect(r.matchedAgent).toBeNull();
|
|
255
|
-
expect(r.agentName).toBe('Stranger');
|
|
256
|
-
expect(r.messageText).toBe('hi');
|
|
257
|
-
});
|
|
258
|
-
|
|
259
|
-
test('handles regex-sensitive characters in agent names safely', () => {
|
|
260
|
-
const dangerous = [{ id: 'a1', name: 'C++.parser' }];
|
|
261
|
-
const svc = makeService({ agents: dangerous });
|
|
262
|
-
const r = svc._resolveAtPrefix('@C++.parser go', dangerous);
|
|
263
|
-
expect(r.matchedAgent?.id).toBe('a1');
|
|
264
|
-
expect(r.messageText).toBe('go');
|
|
265
|
-
});
|
|
266
|
-
|
|
267
|
-
test('many-word names ("Multi Word Long Name") match end-to-end', () => {
|
|
268
|
-
const svc = makeService({ agents });
|
|
269
|
-
const r = svc._resolveAtPrefix('@Multi Word Long Name run the audit', agents);
|
|
270
|
-
expect(r.matchedAgent?.id).toBe('a4');
|
|
271
|
-
expect(r.messageText).toBe('run the audit');
|
|
272
|
-
});
|
|
273
|
-
});
|
|
274
|
-
|
|
275
|
-
// ─────────────────────────────────────────────────────────────────────
|
|
276
|
-
// Multi-chat /start
|
|
277
|
-
// ─────────────────────────────────────────────────────────────────────
|
|
278
|
-
|
|
279
|
-
describe('multi-chat /start', () => {
|
|
280
|
-
test('first chat /start registers; second chat /start ALSO registers (no global lock)', async () => {
|
|
281
|
-
const svc = makeService();
|
|
282
|
-
const bot = await connected(svc);
|
|
283
|
-
await bot.simulateMessage(userMsg(111, '/start'));
|
|
284
|
-
await bot.simulateMessage(userMsg(222, '/start'));
|
|
285
|
-
expect(svc.chats.has('111')).toBe(true);
|
|
286
|
-
expect(svc.chats.has('222')).toBe(true);
|
|
287
|
-
expect(svc.getStatus().chatCount).toBe(2);
|
|
288
|
-
});
|
|
289
|
-
|
|
290
|
-
test('same chat /start twice does NOT duplicate state', async () => {
|
|
291
|
-
const svc = makeService();
|
|
292
|
-
const bot = await connected(svc);
|
|
293
|
-
await bot.simulateMessage(userMsg(111, '/start'));
|
|
294
|
-
await bot.simulateMessage(userMsg(111, '/start'));
|
|
295
|
-
expect(svc.chats.size).toBe(1);
|
|
296
|
-
// Second invocation should reply "Already connected".
|
|
297
|
-
const greetings = bot.calls.sendMessage.filter(c => String(c.chatId) === '111');
|
|
298
|
-
expect(greetings.length).toBeGreaterThanOrEqual(2);
|
|
299
|
-
expect(greetings[1].text).toMatch(/Already connected/);
|
|
300
|
-
});
|
|
301
|
-
});
|
|
302
|
-
|
|
303
|
-
// ─────────────────────────────────────────────────────────────────────
|
|
304
|
-
// /logout, /reset, /new
|
|
305
|
-
// ─────────────────────────────────────────────────────────────────────
|
|
306
|
-
|
|
307
|
-
describe('/logout', () => {
|
|
308
|
-
test('removes chat from registered set', async () => {
|
|
309
|
-
const svc = makeService();
|
|
310
|
-
const bot = await connected(svc);
|
|
311
|
-
await bot.simulateMessage(userMsg(111, '/start'));
|
|
312
|
-
await bot.simulateMessage(userMsg(111, '/logout'));
|
|
313
|
-
expect(svc.chats.has('111')).toBe(false);
|
|
314
|
-
});
|
|
315
|
-
});
|
|
316
|
-
|
|
317
|
-
describe('/reset', () => {
|
|
318
|
-
test('calls resetAgentConversation on the sticky agent', async () => {
|
|
319
|
-
const svc = makeService();
|
|
320
|
-
const bot = await connected(svc);
|
|
321
|
-
await bot.simulateMessage(userMsg(111, '/start'));
|
|
322
|
-
svc.chats.get('111').lastAgentId = 'a1';
|
|
323
|
-
svc.chats.get('111').activeAgentIds.add('a1');
|
|
324
|
-
|
|
325
|
-
await bot.simulateMessage(userMsg(111, '/reset'));
|
|
326
|
-
expect(svc.orchestrator.messageProcessor.resetAgentConversation).toHaveBeenCalledWith('a1');
|
|
327
|
-
});
|
|
328
|
-
|
|
329
|
-
test('returns friendly message when no sticky agent is set', async () => {
|
|
330
|
-
const svc = makeService();
|
|
331
|
-
const bot = await connected(svc);
|
|
332
|
-
await bot.simulateMessage(userMsg(111, '/start'));
|
|
333
|
-
bot.calls.sendMessage = [];
|
|
334
|
-
await bot.simulateMessage(userMsg(111, '/reset'));
|
|
335
|
-
const last = bot.calls.sendMessage[bot.calls.sendMessage.length - 1];
|
|
336
|
-
expect(last.text).toMatch(/No active agent/);
|
|
337
|
-
expect(svc.orchestrator.messageProcessor.resetAgentConversation).not.toHaveBeenCalled();
|
|
338
|
-
});
|
|
339
|
-
});
|
|
340
|
-
|
|
341
|
-
describe('/new <agent>', () => {
|
|
342
|
-
test('switches sticky agent + resets the new one', async () => {
|
|
343
|
-
const svc = makeService();
|
|
344
|
-
const bot = await connected(svc);
|
|
345
|
-
await bot.simulateMessage(userMsg(111, '/start'));
|
|
346
|
-
await bot.simulateMessage(userMsg(111, '/new Lina'));
|
|
347
|
-
expect(svc.chats.get('111').lastAgentId).toBe('a2');
|
|
348
|
-
expect(svc.orchestrator.messageProcessor.resetAgentConversation).toHaveBeenCalledWith('a2');
|
|
349
|
-
});
|
|
350
|
-
|
|
351
|
-
test('unknown agent returns error', async () => {
|
|
352
|
-
const svc = makeService();
|
|
353
|
-
const bot = await connected(svc);
|
|
354
|
-
await bot.simulateMessage(userMsg(111, '/start'));
|
|
355
|
-
bot.calls.sendMessage = [];
|
|
356
|
-
await bot.simulateMessage(userMsg(111, '/new Nobody'));
|
|
357
|
-
expect(bot.calls.sendMessage[0].text).toMatch(/not found/);
|
|
358
|
-
});
|
|
359
|
-
});
|
|
360
|
-
|
|
361
|
-
// ─────────────────────────────────────────────────────────────────────
|
|
362
|
-
// End-to-end @-prefix with spaced name
|
|
363
|
-
// ─────────────────────────────────────────────────────────────────────
|
|
364
|
-
|
|
365
|
-
describe('@-prefix dispatch — name with spaces', () => {
|
|
366
|
-
test('"@John Smith write the report" dispatches to the right agent with the right message', async () => {
|
|
367
|
-
const agents = [
|
|
368
|
-
{ id: 'js-1', name: 'John Smith', mode: 'chat', status: 'idle', currentModel: 'gpt-4.1' },
|
|
369
|
-
{ id: 'cody', name: 'Cody', mode: 'chat', status: 'idle', currentModel: 'gpt-4.1' },
|
|
370
|
-
];
|
|
371
|
-
const svc = makeService({ agents });
|
|
372
|
-
const bot = await connected(svc);
|
|
373
|
-
await bot.simulateMessage(userMsg(111, '/start'));
|
|
374
|
-
|
|
375
|
-
await bot.simulateMessage(userMsg(111, '@John Smith write the report'));
|
|
376
|
-
|
|
377
|
-
expect(svc.orchestrator.processRequest).toHaveBeenCalled();
|
|
378
|
-
const payload = svc.orchestrator.processRequest.mock.calls.slice(-1)[0][0].payload;
|
|
379
|
-
expect(payload.agentId).toBe('js-1');
|
|
380
|
-
expect(payload.message).toBe('write the report');
|
|
381
|
-
});
|
|
382
|
-
|
|
383
|
-
test('unknown @name surfaces a helpful "not found" reply, no orchestrator call', async () => {
|
|
384
|
-
const svc = makeService();
|
|
385
|
-
const bot = await connected(svc);
|
|
386
|
-
await bot.simulateMessage(userMsg(111, '/start'));
|
|
387
|
-
bot.calls.sendMessage = [];
|
|
388
|
-
|
|
389
|
-
await bot.simulateMessage(userMsg(111, '@Nobody hello'));
|
|
390
|
-
|
|
391
|
-
expect(svc.orchestrator.processRequest).not.toHaveBeenCalled();
|
|
392
|
-
const reply = bot.calls.sendMessage[0];
|
|
393
|
-
expect(reply.text).toMatch(/not found/);
|
|
394
|
-
// The friendly tip explaining slug syntax must be present so users
|
|
395
|
-
// who typed an underscore version learn the syntax. Underscores
|
|
396
|
-
// are MarkdownV2-escaped on the wire (`John\_Smith`), so allow
|
|
397
|
-
// any single character between the two halves.
|
|
398
|
-
expect(reply.text).toMatch(/John.Smith/);
|
|
399
|
-
});
|
|
400
|
-
});
|
|
401
|
-
|
|
402
|
-
// ─────────────────────────────────────────────────────────────────────
|
|
403
|
-
// Group chat @mention gating
|
|
404
|
-
// ─────────────────────────────────────────────────────────────────────
|
|
405
|
-
|
|
406
|
-
describe('group chat — @mention required', () => {
|
|
407
|
-
test('plain message in group is IGNORED (no orchestrator call)', async () => {
|
|
408
|
-
const svc = makeService();
|
|
409
|
-
const bot = await connected(svc);
|
|
410
|
-
await bot.simulateMessage(userMsg(-100, '/start', { chatType: 'supergroup', title: 'Ops' }));
|
|
411
|
-
svc.chats.get('-100').lastAgentId = 'a1';
|
|
412
|
-
svc.chats.get('-100').activeAgentIds.add('a1');
|
|
413
|
-
|
|
414
|
-
await bot.simulateMessage(userMsg(-100, 'hello team', { chatType: 'supergroup' }));
|
|
415
|
-
expect(svc.orchestrator.processRequest).not.toHaveBeenCalled();
|
|
416
|
-
});
|
|
417
|
-
|
|
418
|
-
test('@bot message in group strips mention and dispatches', async () => {
|
|
419
|
-
const svc = makeService();
|
|
420
|
-
const bot = await connected(svc);
|
|
421
|
-
await bot.simulateMessage(userMsg(-100, '/start', { chatType: 'supergroup', title: 'Ops' }));
|
|
422
|
-
svc.chats.get('-100').lastAgentId = 'a1';
|
|
423
|
-
svc.chats.get('-100').activeAgentIds.add('a1');
|
|
424
|
-
|
|
425
|
-
await bot.simulateMessage(userMsg(-100, '@loxia_bot summarize last meeting', { chatType: 'supergroup' }));
|
|
426
|
-
expect(svc.orchestrator.processRequest).toHaveBeenCalled();
|
|
427
|
-
const payload = svc.orchestrator.processRequest.mock.calls[0][0].payload;
|
|
428
|
-
expect(payload.message).toBe('summarize last meeting');
|
|
429
|
-
});
|
|
430
|
-
|
|
431
|
-
test('private chat does NOT require @mention', async () => {
|
|
432
|
-
const svc = makeService();
|
|
433
|
-
const bot = await connected(svc);
|
|
434
|
-
await bot.simulateMessage(userMsg(111, '/start'));
|
|
435
|
-
svc.chats.get('111').lastAgentId = 'a1';
|
|
436
|
-
svc.chats.get('111').activeAgentIds.add('a1');
|
|
437
|
-
|
|
438
|
-
await bot.simulateMessage(userMsg(111, 'hi there'));
|
|
439
|
-
expect(svc.orchestrator.processRequest).toHaveBeenCalled();
|
|
440
|
-
});
|
|
441
|
-
});
|
|
442
|
-
|
|
443
|
-
// ─────────────────────────────────────────────────────────────────────
|
|
444
|
-
// Thinking placeholder & edit
|
|
445
|
-
// ─────────────────────────────────────────────────────────────────────
|
|
446
|
-
|
|
447
|
-
describe('thinking placeholder', () => {
|
|
448
|
-
test('typing action + placeholder sent before orchestrator call', async () => {
|
|
449
|
-
const svc = makeService();
|
|
450
|
-
const bot = await connected(svc);
|
|
451
|
-
await bot.simulateMessage(userMsg(111, '/start'));
|
|
452
|
-
await bot.simulateMessage(userMsg(111, '@Cody help me'));
|
|
453
|
-
|
|
454
|
-
expect(bot.calls.sendChatAction).toContainEqual({ chatId: 111, action: 'typing' });
|
|
455
|
-
const thinking = bot.calls.sendMessage.find(c => c.text.includes('thinking'));
|
|
456
|
-
expect(thinking).toBeDefined();
|
|
457
|
-
expect(thinking.chatId).toBe(111);
|
|
458
|
-
});
|
|
459
|
-
|
|
460
|
-
test('placeholder is replaced (edit) when agent emits stream_complete', async () => {
|
|
461
|
-
const svc = makeService();
|
|
462
|
-
const bot = await connected(svc);
|
|
463
|
-
await bot.simulateMessage(userMsg(111, '/start'));
|
|
464
|
-
await bot.simulateMessage(userMsg(111, '@Cody hello'));
|
|
465
|
-
|
|
466
|
-
// Simulate broadcast.
|
|
467
|
-
await svc._handleBroadcastEvent({
|
|
468
|
-
type: 'stream_complete',
|
|
469
|
-
agentId: 'a1',
|
|
470
|
-
content: '<external>Sure — here you go.</external>',
|
|
471
|
-
role: 'assistant',
|
|
472
|
-
});
|
|
473
|
-
|
|
474
|
-
expect(bot.calls.editMessageText).toHaveLength(1);
|
|
475
|
-
expect(bot.calls.editMessageText[0].text).toMatch(/Cody/);
|
|
476
|
-
expect(bot.calls.editMessageText[0].text).toMatch(/Sure/);
|
|
477
|
-
});
|
|
478
|
-
});
|
|
479
|
-
|
|
480
|
-
// ─────────────────────────────────────────────────────────────────────
|
|
481
|
-
// <actions> / <image> / <attachment> blocks
|
|
482
|
-
// ─────────────────────────────────────────────────────────────────────
|
|
483
|
-
|
|
484
|
-
describe('rich reply blocks', () => {
|
|
485
|
-
test('<actions> block becomes inline keyboard with act:agentId:value callbacks', async () => {
|
|
486
|
-
const svc = makeService();
|
|
487
|
-
const bot = await connected(svc);
|
|
488
|
-
await bot.simulateMessage(userMsg(111, '/start'));
|
|
489
|
-
await bot.simulateMessage(userMsg(111, '@Cody what now'));
|
|
490
|
-
|
|
491
|
-
await svc._handleBroadcastEvent({
|
|
492
|
-
type: 'stream_complete', agentId: 'a1', role: 'assistant',
|
|
493
|
-
content:
|
|
494
|
-
'<external>Pick one:\n' +
|
|
495
|
-
'<actions>[{"label":"Continue","value":"go"},{"label":"Restart","value":"reset"}]</actions>' +
|
|
496
|
-
'</external>',
|
|
497
|
-
});
|
|
498
|
-
|
|
499
|
-
expect(bot.calls.editMessageText).toHaveLength(1);
|
|
500
|
-
const opts = bot.calls.editMessageText[0].opts;
|
|
501
|
-
const kb = opts.reply_markup.inline_keyboard;
|
|
502
|
-
expect(kb.map(row => row[0].text)).toEqual(['Continue', 'Restart']);
|
|
503
|
-
expect(kb[0][0].callback_data).toBe('act:a1:go');
|
|
504
|
-
expect(kb[1][0].callback_data).toBe('act:a1:reset');
|
|
505
|
-
});
|
|
506
|
-
|
|
507
|
-
test('button tap dispatches a synthetic agent message', async () => {
|
|
508
|
-
const svc = makeService();
|
|
509
|
-
const bot = await connected(svc);
|
|
510
|
-
await bot.simulateMessage(userMsg(111, '/start'));
|
|
511
|
-
svc.chats.get('111').activeAgentIds.add('a1');
|
|
512
|
-
svc.chats.get('111').lastAgentId = 'a1';
|
|
513
|
-
|
|
514
|
-
await bot.simulateCallback({
|
|
515
|
-
id: 'cb-1',
|
|
516
|
-
data: 'act:a1:keep going',
|
|
517
|
-
message: { chat: { id: 111, type: 'private' }, message_id: 999 },
|
|
518
|
-
from: { username: 'alice' },
|
|
519
|
-
});
|
|
520
|
-
expect(svc.orchestrator.processRequest).toHaveBeenCalled();
|
|
521
|
-
const payload = svc.orchestrator.processRequest.mock.calls.slice(-1)[0][0].payload;
|
|
522
|
-
expect(payload.message).toBe('keep going');
|
|
523
|
-
});
|
|
524
|
-
|
|
525
|
-
test('<image> block triggers sendPhoto', async () => {
|
|
526
|
-
const svc = makeService();
|
|
527
|
-
const bot = await connected(svc);
|
|
528
|
-
await bot.simulateMessage(userMsg(111, '/start'));
|
|
529
|
-
await bot.simulateMessage(userMsg(111, '@Cody chart'));
|
|
530
|
-
|
|
531
|
-
await svc._handleBroadcastEvent({
|
|
532
|
-
type: 'stream_complete', agentId: 'a1', role: 'assistant',
|
|
533
|
-
content: '<external>Here it is:\n<image src="https://example.com/chart.png">Q4 chart</image></external>',
|
|
534
|
-
});
|
|
535
|
-
expect(bot.calls.sendPhoto).toHaveLength(1);
|
|
536
|
-
expect(String(bot.calls.sendPhoto[0].chatId)).toBe('111');
|
|
537
|
-
expect(bot.calls.sendPhoto[0].photo).toBe('https://example.com/chart.png');
|
|
538
|
-
});
|
|
539
|
-
|
|
540
|
-
test('<attachment> block triggers sendDocument', async () => {
|
|
541
|
-
const svc = makeService();
|
|
542
|
-
const bot = await connected(svc);
|
|
543
|
-
await bot.simulateMessage(userMsg(111, '/start'));
|
|
544
|
-
await bot.simulateMessage(userMsg(111, '@Cody report'));
|
|
545
|
-
|
|
546
|
-
await svc._handleBroadcastEvent({
|
|
547
|
-
type: 'stream_complete', agentId: 'a1', role: 'assistant',
|
|
548
|
-
content: '<external>See report:\n<attachment src="/tmp/r.pdf" type="application/pdf">Report</attachment></external>',
|
|
549
|
-
});
|
|
550
|
-
expect(bot.calls.sendDocument).toHaveLength(1);
|
|
551
|
-
expect(String(bot.calls.sendDocument[0].chatId)).toBe('111');
|
|
552
|
-
expect(bot.calls.sendDocument[0].doc).toBe('/tmp/r.pdf');
|
|
553
|
-
});
|
|
554
|
-
});
|
|
555
|
-
|
|
556
|
-
// ─────────────────────────────────────────────────────────────────────
|
|
557
|
-
// Always-relay errors / watch-gated completions
|
|
558
|
-
// ─────────────────────────────────────────────────────────────────────
|
|
559
|
-
|
|
560
|
-
describe('error fan-out vs watch-gating', () => {
|
|
561
|
-
test('agent_error relays to a chat that has NOT subscribed via /watch', async () => {
|
|
562
|
-
const svc = makeService();
|
|
563
|
-
const bot = await connected(svc);
|
|
564
|
-
await bot.simulateMessage(userMsg(111, '/start'));
|
|
565
|
-
expect(svc.chats.get('111').watchEnabled).toBe(false);
|
|
566
|
-
|
|
567
|
-
await svc._handleBroadcastEvent({
|
|
568
|
-
type: 'agent_error', agentId: 'a1', agentName: 'Cody', message: 'boom',
|
|
569
|
-
});
|
|
570
|
-
const sent = bot.calls.sendMessage.find(c => c.text.includes('Agent Error'));
|
|
571
|
-
expect(sent).toBeDefined();
|
|
572
|
-
});
|
|
573
|
-
|
|
574
|
-
test('agent_error dedup: identical errors within the window only send once', async () => {
|
|
575
|
-
const svc = makeService();
|
|
576
|
-
const bot = await connected(svc);
|
|
577
|
-
await bot.simulateMessage(userMsg(111, '/start'));
|
|
578
|
-
|
|
579
|
-
const payload = { type: 'agent_error', agentId: 'a1', agentName: 'Cody', message: 'boom' };
|
|
580
|
-
await svc._handleBroadcastEvent(payload);
|
|
581
|
-
await svc._handleBroadcastEvent(payload);
|
|
582
|
-
await svc._handleBroadcastEvent(payload);
|
|
583
|
-
await svc._handleBroadcastEvent(payload);
|
|
584
|
-
|
|
585
|
-
const errorMessages = bot.calls.sendMessage.filter(c => c.text.includes('Agent Error'));
|
|
586
|
-
// Only the first one should have made it through; the next three are
|
|
587
|
-
// counted but suppressed.
|
|
588
|
-
expect(errorMessages).toHaveLength(1);
|
|
589
|
-
// Dedup state confirms three were absorbed.
|
|
590
|
-
const entry = [...svc._errorBroadcastDedup.values()][0];
|
|
591
|
-
expect(entry?.count).toBe(4);
|
|
592
|
-
});
|
|
593
|
-
|
|
594
|
-
test('agent_error dedup: different agents do NOT collide', async () => {
|
|
595
|
-
const svc = makeService();
|
|
596
|
-
const bot = await connected(svc);
|
|
597
|
-
await bot.simulateMessage(userMsg(111, '/start'));
|
|
598
|
-
|
|
599
|
-
await svc._handleBroadcastEvent({ type: 'agent_error', agentId: 'a1', agentName: 'Cody', message: 'same' });
|
|
600
|
-
await svc._handleBroadcastEvent({ type: 'agent_error', agentId: 'a2', agentName: 'Carol', message: 'same' });
|
|
601
|
-
|
|
602
|
-
const errorMessages = bot.calls.sendMessage.filter(c => c.text.includes('Agent Error'));
|
|
603
|
-
expect(errorMessages).toHaveLength(2);
|
|
604
|
-
});
|
|
605
|
-
|
|
606
|
-
test('agent_error dedup: textual variations of same error coalesce', async () => {
|
|
607
|
-
const svc = makeService();
|
|
608
|
-
const bot = await connected(svc);
|
|
609
|
-
await bot.simulateMessage(userMsg(111, '/start'));
|
|
610
|
-
|
|
611
|
-
// Same error, formatted slightly differently — should dedup.
|
|
612
|
-
await svc._handleBroadcastEvent({ type: 'agent_error', agentId: 'a1', message: 'Connection refused' });
|
|
613
|
-
await svc._handleBroadcastEvent({ type: 'agent_error', agentId: 'a1', message: 'connection refused' });
|
|
614
|
-
await svc._handleBroadcastEvent({ type: 'agent_error', agentId: 'a1', message: ' Connection refused ' });
|
|
615
|
-
|
|
616
|
-
const errorMessages = bot.calls.sendMessage.filter(c => c.text.includes('Agent Error'));
|
|
617
|
-
expect(errorMessages).toHaveLength(1);
|
|
618
|
-
});
|
|
619
|
-
|
|
620
|
-
test('agent_error dedup: window close emits a "still happening" summary when count > 1', async () => {
|
|
621
|
-
jest.useFakeTimers();
|
|
622
|
-
try {
|
|
623
|
-
const svc = makeService();
|
|
624
|
-
const bot = await connected(svc);
|
|
625
|
-
await bot.simulateMessage(userMsg(111, '/start'));
|
|
626
|
-
|
|
627
|
-
// First fires immediately, plus two suppressed.
|
|
628
|
-
await svc._handleBroadcastEvent({ type: 'agent_error', agentId: 'a1', agentName: 'Cody', message: 'boom' });
|
|
629
|
-
await svc._handleBroadcastEvent({ type: 'agent_error', agentId: 'a1', agentName: 'Cody', message: 'boom' });
|
|
630
|
-
await svc._handleBroadcastEvent({ type: 'agent_error', agentId: 'a1', agentName: 'Cody', message: 'boom' });
|
|
631
|
-
|
|
632
|
-
// Advance past the 5-minute dedup window.
|
|
633
|
-
jest.advanceTimersByTime(5 * 60 * 1000 + 100);
|
|
634
|
-
// Let the timer callback's microtasks resolve.
|
|
635
|
-
await Promise.resolve();
|
|
636
|
-
await Promise.resolve();
|
|
637
|
-
|
|
638
|
-
const summary = bot.calls.sendMessage.find(c => c.text.includes('still happening'));
|
|
639
|
-
expect(summary).toBeDefined();
|
|
640
|
-
expect(summary.text).toMatch(/2 more/);
|
|
641
|
-
} finally {
|
|
642
|
-
jest.useRealTimers();
|
|
643
|
-
}
|
|
644
|
-
});
|
|
645
|
-
|
|
646
|
-
test('agent_error dedup: a single error does NOT trigger a summary', async () => {
|
|
647
|
-
jest.useFakeTimers();
|
|
648
|
-
try {
|
|
649
|
-
const svc = makeService();
|
|
650
|
-
const bot = await connected(svc);
|
|
651
|
-
await bot.simulateMessage(userMsg(111, '/start'));
|
|
652
|
-
|
|
653
|
-
await svc._handleBroadcastEvent({ type: 'agent_error', agentId: 'a1', message: 'one-off' });
|
|
654
|
-
jest.advanceTimersByTime(5 * 60 * 1000 + 100);
|
|
655
|
-
await Promise.resolve();
|
|
656
|
-
|
|
657
|
-
// The original message went out. No follow-up summary.
|
|
658
|
-
const summary = bot.calls.sendMessage.find(c => c.text.includes('still happening'));
|
|
659
|
-
expect(summary).toBeUndefined();
|
|
660
|
-
} finally {
|
|
661
|
-
jest.useRealTimers();
|
|
662
|
-
}
|
|
663
|
-
});
|
|
664
|
-
|
|
665
|
-
test('execution_stopped does NOT relay unless /watch is on', async () => {
|
|
666
|
-
const svc = makeService();
|
|
667
|
-
const bot = await connected(svc);
|
|
668
|
-
await bot.simulateMessage(userMsg(111, '/start'));
|
|
669
|
-
|
|
670
|
-
await svc._handleBroadcastEvent({
|
|
671
|
-
type: 'execution_stopped', agentId: 'a1', agentName: 'Cody', message: 'done',
|
|
672
|
-
});
|
|
673
|
-
// Notifications are batched on a timer, so we just assert nothing
|
|
674
|
-
// queued at the immediate level.
|
|
675
|
-
expect(bot.calls.sendMessage.find(c => c.text.includes('Agent Finished'))).toBeUndefined();
|
|
676
|
-
});
|
|
677
|
-
|
|
678
|
-
test('execution_stopped relays when /watch is on', async () => {
|
|
679
|
-
jest.useFakeTimers();
|
|
680
|
-
try {
|
|
681
|
-
const svc = makeService();
|
|
682
|
-
const bot = await connected(svc);
|
|
683
|
-
await bot.simulateMessage(userMsg(111, '/start'));
|
|
684
|
-
await bot.simulateMessage(userMsg(111, '/watch'));
|
|
685
|
-
|
|
686
|
-
await svc._handleBroadcastEvent({
|
|
687
|
-
type: 'execution_stopped', agentId: 'a1', agentName: 'Cody', message: 'done',
|
|
688
|
-
});
|
|
689
|
-
jest.advanceTimersByTime(11000); // flush batch interval (10s)
|
|
690
|
-
await Promise.resolve(); // microtask drain
|
|
691
|
-
// Sent messages are async — check that the queue WOULD have been flushed.
|
|
692
|
-
expect(svc.notificationQueue.size === 0 || bot.calls.sendMessage.some(c => c.text.includes('Agent Finished')))
|
|
693
|
-
.toBe(true);
|
|
694
|
-
} finally {
|
|
695
|
-
jest.useRealTimers();
|
|
696
|
-
}
|
|
697
|
-
});
|
|
698
|
-
});
|
|
699
|
-
|
|
700
|
-
// ─────────────────────────────────────────────────────────────────────
|
|
701
|
-
// Pagination
|
|
702
|
-
// ─────────────────────────────────────────────────────────────────────
|
|
703
|
-
|
|
704
|
-
describe('paginated /agents', () => {
|
|
705
|
-
test('first page only shows PAGE_SIZE entries when many agents exist', async () => {
|
|
706
|
-
const many = Array.from({ length: 20 }, (_, i) => ({
|
|
707
|
-
id: `a${i}`, name: `Agent${i}`, mode: 'chat', status: 'idle',
|
|
708
|
-
}));
|
|
709
|
-
const svc = makeService({ agents: many });
|
|
710
|
-
const bot = await connected(svc);
|
|
711
|
-
await bot.simulateMessage(userMsg(111, '/start'));
|
|
712
|
-
bot.calls.sendMessage = [];
|
|
713
|
-
await bot.simulateMessage(userMsg(111, '/agents'));
|
|
714
|
-
|
|
715
|
-
const listMsg = bot.calls.sendMessage.find(c => c.text.includes('Agents (20)'));
|
|
716
|
-
expect(listMsg).toBeDefined();
|
|
717
|
-
// Page 1/3 of 20 with PAGE_SIZE=8 → 8 entries shown.
|
|
718
|
-
const buttons = listMsg.opts.reply_markup.inline_keyboard;
|
|
719
|
-
expect(buttons.filter(row => row[0].callback_data?.startsWith('agent_detail:'))).toHaveLength(8);
|
|
720
|
-
// Last row is the pagination nav.
|
|
721
|
-
const nav = buttons[buttons.length - 1];
|
|
722
|
-
expect(nav.some(b => b.callback_data?.startsWith('agents_page:'))).toBe(true);
|
|
723
|
-
});
|
|
724
|
-
});
|
|
725
|
-
|
|
726
|
-
// ─────────────────────────────────────────────────────────────────────
|
|
727
|
-
// Markdown vs HTML vs document selection
|
|
728
|
-
// ─────────────────────────────────────────────────────────────────────
|
|
729
|
-
|
|
730
|
-
describe('text formatting selection', () => {
|
|
731
|
-
test('plain text uses MarkdownV2', () => {
|
|
732
|
-
const svc = makeService();
|
|
733
|
-
const out = svc._formatAgentBody('hello world.');
|
|
734
|
-
expect(out.parseMode).toBe('MarkdownV2');
|
|
735
|
-
expect(out.body).toContain('hello world\\.'); // dot escaped
|
|
736
|
-
});
|
|
737
|
-
|
|
738
|
-
test('short fenced code uses HTML', () => {
|
|
739
|
-
const svc = makeService();
|
|
740
|
-
const out = svc._formatAgentBody('See:\n```js\nconst x = 1;\n```');
|
|
741
|
-
expect(out.parseMode).toBe('HTML');
|
|
742
|
-
expect(out.body).toMatch(/<pre>const x = 1;/);
|
|
743
|
-
});
|
|
744
|
-
|
|
745
|
-
test('long fenced code (> threshold) is sent as document', () => {
|
|
746
|
-
const svc = makeService();
|
|
747
|
-
const big = 'x = 1;\n'.repeat(800);
|
|
748
|
-
const out = svc._formatAgentBody('```py\n' + big + '\n```');
|
|
749
|
-
expect(out.kind).toBe('document');
|
|
750
|
-
});
|
|
751
|
-
});
|
|
752
|
-
|
|
753
|
-
// ─────────────────────────────────────────────────────────────────────
|
|
754
|
-
// Config migration
|
|
755
|
-
// ─────────────────────────────────────────────────────────────────────
|
|
756
|
-
|
|
757
|
-
describe('config migration', () => {
|
|
758
|
-
test('legacy { chatId: "x", watchEnabled: true } loads as a single-chat entry', async () => {
|
|
759
|
-
virtualFs.content = JSON.stringify({ chatId: 'X1', watchEnabled: true, botToken: 'T' });
|
|
760
|
-
const svc = makeService();
|
|
761
|
-
await svc._loadConfig();
|
|
762
|
-
expect(svc.chats.has('X1')).toBe(true);
|
|
763
|
-
expect(svc.chats.get('X1').watchEnabled).toBe(true);
|
|
764
|
-
});
|
|
765
|
-
|
|
766
|
-
test('legacy fields are dropped on next save', async () => {
|
|
767
|
-
virtualFs.content = JSON.stringify({ chatId: 'X1', watchEnabled: true, botToken: 'T' });
|
|
768
|
-
const svc = makeService();
|
|
769
|
-
await svc._loadConfig();
|
|
770
|
-
await svc._saveConfig();
|
|
771
|
-
const persisted = JSON.parse(virtualFs.content);
|
|
772
|
-
expect(persisted.chatId).toBeUndefined();
|
|
773
|
-
expect(persisted.watchEnabled).toBeUndefined();
|
|
774
|
-
expect(persisted.chats).toHaveLength(1);
|
|
775
|
-
expect(persisted.chats[0].chatId).toBe('X1');
|
|
776
|
-
});
|
|
777
|
-
});
|
|
778
|
-
|
|
779
|
-
// ─────────────────────────────────────────────────────────────────────
|
|
780
|
-
// Bridged channels per-chat
|
|
781
|
-
// ─────────────────────────────────────────────────────────────────────
|
|
782
|
-
|
|
783
|
-
describe('getBridgedChannels', () => {
|
|
784
|
-
test('returns one alias per chat the agent is active in, plus a bare "telegram" alias', async () => {
|
|
785
|
-
const svc = makeService();
|
|
786
|
-
const bot = await connected(svc);
|
|
787
|
-
await bot.simulateMessage(userMsg(111, '/start'));
|
|
788
|
-
await bot.simulateMessage(userMsg(222, '/start'));
|
|
789
|
-
svc.chats.get('111').activeAgentIds.add('a1');
|
|
790
|
-
svc.chats.get('222').activeAgentIds.add('a1');
|
|
791
|
-
|
|
792
|
-
const bridged = svc.getBridgedChannels('a1');
|
|
793
|
-
const aliases = bridged.map(c => c.alias);
|
|
794
|
-
expect(aliases).toEqual(expect.arrayContaining([
|
|
795
|
-
'telegram:chat-111',
|
|
796
|
-
'telegram:chat-222',
|
|
797
|
-
'telegram',
|
|
798
|
-
]));
|
|
799
|
-
});
|
|
800
|
-
|
|
801
|
-
test('returns empty array when agent has no chat', async () => {
|
|
802
|
-
const svc = makeService();
|
|
803
|
-
await connected(svc);
|
|
804
|
-
expect(svc.getBridgedChannels('a1')).toEqual([]);
|
|
805
|
-
});
|
|
806
|
-
});
|
|
807
|
-
|
|
808
|
-
// ─────────────────────────────────────────────────────────────────────
|
|
809
|
-
// imageGenerated → Telegram relay
|
|
810
|
-
//
|
|
811
|
-
// Background: the imageTool emits a separate broadcast (`imageGenerated`)
|
|
812
|
-
// alongside the agent's textual `stream_complete`. Before this fix the
|
|
813
|
-
// `imageGenerated` event was silently dropped and the user — who'd
|
|
814
|
-
// asked the bot to "draw me X" — saw no image come back to the chat.
|
|
815
|
-
//
|
|
816
|
-
// Behavior we lock in:
|
|
817
|
-
// - imageGenerated is buffered per agent, flushed on the next
|
|
818
|
-
// stream_complete for the same agent into every active chat that
|
|
819
|
-
// has that agent linked.
|
|
820
|
-
// - dedupes against images the agent already embedded as
|
|
821
|
-
// `<image src="…">` markup so we don't double-post.
|
|
822
|
-
// - works even when the agent's text reply has no `<external>` block
|
|
823
|
-
// at all (image-only turn).
|
|
824
|
-
// - skips chats where the agent isn't active (multi-chat isolation).
|
|
825
|
-
// ─────────────────────────────────────────────────────────────────────
|
|
826
|
-
|
|
827
|
-
describe('imageGenerated relay', () => {
|
|
828
|
-
test('imageGenerated buffered, flushed on stream_complete into the active chat', async () => {
|
|
829
|
-
const svc = makeService();
|
|
830
|
-
const bot = await connected(svc);
|
|
831
|
-
await bot.simulateMessage(userMsg(111, '/start'));
|
|
832
|
-
await bot.simulateMessage(userMsg(111, '@Cody draw a sunset'));
|
|
833
|
-
|
|
834
|
-
// Image arrives from the tool first…
|
|
835
|
-
await svc._handleBroadcastEvent({
|
|
836
|
-
type: 'imageGenerated', agentId: 'a1',
|
|
837
|
-
imageUrl: 'https://cdn.example/sunset.png',
|
|
838
|
-
prompt: 'a sunset over Paris',
|
|
839
|
-
success: true,
|
|
840
|
-
});
|
|
841
|
-
// Bot should not have posted the photo yet — we wait for the
|
|
842
|
-
// agent's text completion to keep the order stable.
|
|
843
|
-
expect(bot.calls.sendPhoto).toHaveLength(0);
|
|
844
|
-
|
|
845
|
-
// Then the agent's text completion arrives.
|
|
846
|
-
await svc._handleBroadcastEvent({
|
|
847
|
-
type: 'stream_complete', agentId: 'a1', role: 'assistant',
|
|
848
|
-
content: '<external>Here is your sunset.</external>',
|
|
849
|
-
});
|
|
850
|
-
|
|
851
|
-
expect(bot.calls.sendPhoto).toHaveLength(1);
|
|
852
|
-
expect(bot.calls.sendPhoto[0].photo).toBe('https://cdn.example/sunset.png');
|
|
853
|
-
expect(String(bot.calls.sendPhoto[0].chatId)).toBe('111');
|
|
854
|
-
});
|
|
855
|
-
|
|
856
|
-
test('still flushes even when stream_complete has no <external> block', async () => {
|
|
857
|
-
const svc = makeService();
|
|
858
|
-
const bot = await connected(svc);
|
|
859
|
-
await bot.simulateMessage(userMsg(111, '/start'));
|
|
860
|
-
await bot.simulateMessage(userMsg(111, '@Cody draw'));
|
|
861
|
-
|
|
862
|
-
await svc._handleBroadcastEvent({
|
|
863
|
-
type: 'imageGenerated', agentId: 'a1',
|
|
864
|
-
imageUrl: 'https://cdn.example/only.png',
|
|
865
|
-
success: true,
|
|
866
|
-
});
|
|
867
|
-
await svc._handleBroadcastEvent({
|
|
868
|
-
type: 'stream_complete', agentId: 'a1', role: 'assistant',
|
|
869
|
-
content: 'internal-only narration, no external block',
|
|
870
|
-
});
|
|
871
|
-
expect(bot.calls.sendPhoto).toHaveLength(1);
|
|
872
|
-
expect(bot.calls.sendPhoto[0].photo).toBe('https://cdn.example/only.png');
|
|
873
|
-
});
|
|
874
|
-
|
|
875
|
-
test('deduplicates against images the agent already embedded in <external>', async () => {
|
|
876
|
-
const svc = makeService();
|
|
877
|
-
const bot = await connected(svc);
|
|
878
|
-
await bot.simulateMessage(userMsg(111, '/start'));
|
|
879
|
-
await bot.simulateMessage(userMsg(111, '@Cody draw'));
|
|
880
|
-
|
|
881
|
-
const url = 'https://cdn.example/dup.png';
|
|
882
|
-
await svc._handleBroadcastEvent({
|
|
883
|
-
type: 'imageGenerated', agentId: 'a1', imageUrl: url, success: true,
|
|
884
|
-
});
|
|
885
|
-
await svc._handleBroadcastEvent({
|
|
886
|
-
type: 'stream_complete', agentId: 'a1', role: 'assistant',
|
|
887
|
-
content: `<external>See:\n<image src="${url}">my pic</image></external>`,
|
|
888
|
-
});
|
|
889
|
-
expect(bot.calls.sendPhoto).toHaveLength(1);
|
|
890
|
-
});
|
|
891
|
-
|
|
892
|
-
test('does NOT leak the image to a chat where the agent isn\'t active', async () => {
|
|
893
|
-
const svc = makeService();
|
|
894
|
-
const bot = await connected(svc);
|
|
895
|
-
await bot.simulateMessage(userMsg(111, '/start'));
|
|
896
|
-
await bot.simulateMessage(userMsg(222, '/start'));
|
|
897
|
-
await bot.simulateMessage(userMsg(111, '@Cody draw'));
|
|
898
|
-
// Chat 222 never @-mentioned the agent → activeAgentIds is empty.
|
|
899
|
-
expect(svc.chats.get('222').activeAgentIds.has('a1')).toBe(false);
|
|
900
|
-
|
|
901
|
-
await svc._handleBroadcastEvent({
|
|
902
|
-
type: 'imageGenerated', agentId: 'a1',
|
|
903
|
-
imageUrl: 'https://cdn.example/private.png',
|
|
904
|
-
success: true,
|
|
905
|
-
});
|
|
906
|
-
await svc._handleBroadcastEvent({
|
|
907
|
-
type: 'stream_complete', agentId: 'a1', role: 'assistant',
|
|
908
|
-
content: '<external>done</external>',
|
|
909
|
-
});
|
|
910
|
-
|
|
911
|
-
const chats = bot.calls.sendPhoto.map(c => String(c.chatId));
|
|
912
|
-
expect(chats).toEqual(['111']);
|
|
913
|
-
});
|
|
914
|
-
|
|
915
|
-
test('buffer drops stale entries (>5 min) on next insert', async () => {
|
|
916
|
-
// Direct unit test of the buffer helper — bypasses the
|
|
917
|
-
// `_handleBroadcastEvent` early-return guard (which requires a
|
|
918
|
-
// connected bot + a /start'ed chat) so we can focus on the TTL.
|
|
919
|
-
const svc = makeService();
|
|
920
|
-
const original = Date.now;
|
|
921
|
-
try {
|
|
922
|
-
let now = 1_000_000_000_000;
|
|
923
|
-
Date.now = () => now;
|
|
924
|
-
|
|
925
|
-
svc._bufferGeneratedImage({ agentId: 'a1', imageUrl: 'https://cdn.example/stale.png' });
|
|
926
|
-
now += 6 * 60 * 1000; // 6 min later
|
|
927
|
-
svc._bufferGeneratedImage({ agentId: 'a1', imageUrl: 'https://cdn.example/fresh.png' });
|
|
928
|
-
const remaining = svc._pendingImagesByAgent.get('a1');
|
|
929
|
-
expect(remaining.map(e => e.imageUrl)).toEqual(['https://cdn.example/fresh.png']);
|
|
930
|
-
} finally {
|
|
931
|
-
Date.now = original;
|
|
932
|
-
}
|
|
933
|
-
});
|
|
934
|
-
|
|
935
|
-
test('imageGenerated with no imageUrl is ignored (no crash)', () => {
|
|
936
|
-
const svc = makeService();
|
|
937
|
-
svc._bufferGeneratedImage({ agentId: 'a1' });
|
|
938
|
-
expect(svc._pendingImagesByAgent.has('a1')).toBe(false);
|
|
939
|
-
});
|
|
940
|
-
});
|
|
941
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the rewritten TelegramService.
|
|
3
|
+
*
|
|
4
|
+
* Covers the user-experience-critical surfaces:
|
|
5
|
+
* • Multi-chat /start (no global lock)
|
|
6
|
+
* • Group chat @mention gating
|
|
7
|
+
* • Voice → backend transcribe → text dispatch
|
|
8
|
+
* • Thinking placeholder edit-on-completion
|
|
9
|
+
* • <actions> / <image> / <attachment> block dispatch
|
|
10
|
+
* • Always-relay error broadcasts vs watch-gated completions
|
|
11
|
+
* • /reset, /new, /logout commands
|
|
12
|
+
* • Pagination of /agents
|
|
13
|
+
* • Markdown vs HTML vs document selection by content shape
|
|
14
|
+
* • Config migration (legacy `chatId` scalar)
|
|
15
|
+
*
|
|
16
|
+
* The mock TelegramBot records every call and exposes them via
|
|
17
|
+
* `bot.calls.<method>` arrays. node-telegram-bot-api is fully mocked
|
|
18
|
+
* so no network and no dependency on the real bot library.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { jest, describe, test, expect, beforeEach } from '@jest/globals';
|
|
22
|
+
|
|
23
|
+
// ─── Module mocks ─────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
// In-memory virtual fs for the config file. Each beforeEach resets it.
|
|
26
|
+
const virtualFs = { content: null };
|
|
27
|
+
|
|
28
|
+
jest.unstable_mockModule('fs', () => ({
|
|
29
|
+
promises: {
|
|
30
|
+
mkdir: jest.fn().mockResolvedValue(undefined),
|
|
31
|
+
readFile: jest.fn().mockImplementation(async () => {
|
|
32
|
+
if (virtualFs.content === null) throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
|
|
33
|
+
return virtualFs.content;
|
|
34
|
+
}),
|
|
35
|
+
writeFile: jest.fn().mockImplementation(async (_p, data) => {
|
|
36
|
+
virtualFs.content = data;
|
|
37
|
+
}),
|
|
38
|
+
},
|
|
39
|
+
}));
|
|
40
|
+
|
|
41
|
+
jest.unstable_mockModule('../../utilities/userDataDir.js', () => ({
|
|
42
|
+
getUserDataPaths: () => ({ base: '/mock/data' }),
|
|
43
|
+
ensureUserDataDirs: jest.fn().mockResolvedValue(undefined),
|
|
44
|
+
}));
|
|
45
|
+
|
|
46
|
+
// MockBot — a deterministic stand-in for node-telegram-bot-api. Records
|
|
47
|
+
// every call, exposes hooks so tests can simulate incoming messages.
|
|
48
|
+
class MockBot {
|
|
49
|
+
constructor() {
|
|
50
|
+
this._textHandlers = []; // [{regex, fn}]
|
|
51
|
+
this._messageHandlers = [];
|
|
52
|
+
this._callbackHandlers = [];
|
|
53
|
+
this.calls = {
|
|
54
|
+
sendMessage: [], sendPhoto: [], sendDocument: [],
|
|
55
|
+
sendChatAction: [], editMessageText: [], deleteMessage: [],
|
|
56
|
+
answerCallbackQuery: [],
|
|
57
|
+
};
|
|
58
|
+
this.nextMessageId = 100;
|
|
59
|
+
this.stopPolling = jest.fn().mockResolvedValue(undefined);
|
|
60
|
+
}
|
|
61
|
+
async getMe() { return { username: 'loxia_bot', id: 1 }; }
|
|
62
|
+
onText(regex, fn) { this._textHandlers.push({ regex, fn }); }
|
|
63
|
+
on(event, fn) {
|
|
64
|
+
if (event === 'message') this._messageHandlers.push(fn);
|
|
65
|
+
else if (event === 'callback_query') this._callbackHandlers.push(fn);
|
|
66
|
+
}
|
|
67
|
+
async sendMessage(chatId, text, opts) {
|
|
68
|
+
const id = this.nextMessageId++;
|
|
69
|
+
this.calls.sendMessage.push({ chatId, text, opts, message_id: id });
|
|
70
|
+
return { message_id: id, chat: { id: chatId } };
|
|
71
|
+
}
|
|
72
|
+
async editMessageText(text, opts) { this.calls.editMessageText.push({ text, opts }); return { ok: true }; }
|
|
73
|
+
async deleteMessage(chatId, message_id) { this.calls.deleteMessage.push({ chatId, message_id }); }
|
|
74
|
+
async sendChatAction(chatId, action) { this.calls.sendChatAction.push({ chatId, action }); }
|
|
75
|
+
async sendPhoto(chatId, photo, opts) { this.calls.sendPhoto.push({ chatId, photo, opts }); }
|
|
76
|
+
async sendDocument(chatId, doc, opts) { this.calls.sendDocument.push({ chatId, doc, opts }); }
|
|
77
|
+
async answerCallbackQuery(id) { this.calls.answerCallbackQuery.push(id); }
|
|
78
|
+
async getFileLink(fileId) { return `https://t.example/file/${fileId}`; }
|
|
79
|
+
|
|
80
|
+
// Test helpers: simulate an incoming message / callback.
|
|
81
|
+
async simulateMessage(msg) {
|
|
82
|
+
// Telegram fires onText first when text matches a regex, then `message` listeners.
|
|
83
|
+
if (typeof msg.text === 'string') {
|
|
84
|
+
for (const { regex, fn } of this._textHandlers) {
|
|
85
|
+
const m = msg.text.match(regex);
|
|
86
|
+
if (m) await fn(msg, m);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
for (const fn of this._messageHandlers) await fn(msg);
|
|
90
|
+
}
|
|
91
|
+
async simulateCallback(query) {
|
|
92
|
+
for (const fn of this._callbackHandlers) await fn(query);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
let currentBot;
|
|
97
|
+
// We migrated off node-telegram-bot-api to telegraf via a thin compat
|
|
98
|
+
// wrapper at src/services/telegrafBot.js. The MockBot above implements
|
|
99
|
+
// exactly the wrapper's surface (sendMessage, editMessageText,
|
|
100
|
+
// deleteMessage, sendChatAction, sendPhoto, sendDocument,
|
|
101
|
+
// answerCallbackQuery, getFileLink, getMe, stopPolling, onText, on) so
|
|
102
|
+
// retargeting the mock to the wrapper module is the entire test-side
|
|
103
|
+
// change. telegramService.js calls `createTelegrafBot(token, opts)` —
|
|
104
|
+
// match that shape (async factory returning the wrapper).
|
|
105
|
+
jest.unstable_mockModule('../telegrafBot.js', () => ({
|
|
106
|
+
createTelegrafBot: jest.fn(async () => {
|
|
107
|
+
currentBot = new MockBot();
|
|
108
|
+
return currentBot;
|
|
109
|
+
}),
|
|
110
|
+
default: jest.fn(async () => {
|
|
111
|
+
currentBot = new MockBot();
|
|
112
|
+
return currentBot;
|
|
113
|
+
}),
|
|
114
|
+
}));
|
|
115
|
+
|
|
116
|
+
// ─── Imports after mocks ──────────────────────────────────────────────
|
|
117
|
+
|
|
118
|
+
const { TelegramService, _resetTelegramSingletonForTests } = await import('../telegramService.js');
|
|
119
|
+
|
|
120
|
+
// ─── Test helpers ─────────────────────────────────────────────────────
|
|
121
|
+
|
|
122
|
+
const SAMPLE_AGENTS = [
|
|
123
|
+
{ id: 'a1', name: 'Cody', mode: 'chat', status: 'idle', currentModel: 'gpt-4.1' },
|
|
124
|
+
{ id: 'a2', name: 'Lina', mode: 'auto', status: 'active', currentModel: 'claude-sonnet-4-5' },
|
|
125
|
+
];
|
|
126
|
+
|
|
127
|
+
function makeService({ agents = SAMPLE_AGENTS, withOrchestrator = true } = {}) {
|
|
128
|
+
const svc = new TelegramService(null);
|
|
129
|
+
svc.setAgentPool({
|
|
130
|
+
getAllAgents: jest.fn().mockResolvedValue(agents),
|
|
131
|
+
getAgent: jest.fn((id) => Promise.resolve(agents.find(a => a.id === id) || null)),
|
|
132
|
+
});
|
|
133
|
+
if (withOrchestrator) {
|
|
134
|
+
svc.setOrchestrator({
|
|
135
|
+
config: { project: { directory: '/proj' } },
|
|
136
|
+
processRequest: jest.fn().mockResolvedValue(undefined),
|
|
137
|
+
messageProcessor: {
|
|
138
|
+
stopAutonomousExecution: jest.fn().mockResolvedValue(undefined),
|
|
139
|
+
resetAgentConversation: jest.fn().mockResolvedValue(undefined),
|
|
140
|
+
},
|
|
141
|
+
stateManager: {
|
|
142
|
+
loadFlowIndex: jest.fn().mockResolvedValue({}),
|
|
143
|
+
},
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
return svc;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function userMsg(chatId, text, { chatType = 'private', from = { username: 'alice' }, title } = {}) {
|
|
150
|
+
return {
|
|
151
|
+
chat: { id: chatId, type: chatType, title },
|
|
152
|
+
from,
|
|
153
|
+
text,
|
|
154
|
+
message_id: 1,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
beforeEach(() => {
|
|
159
|
+
virtualFs.content = null;
|
|
160
|
+
_resetTelegramSingletonForTests();
|
|
161
|
+
currentBot = null;
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
async function connected(svc) {
|
|
165
|
+
await svc.connect('TEST_TOKEN');
|
|
166
|
+
return currentBot;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
170
|
+
// @-prefix resolution (names with spaces, slug form, ambiguity)
|
|
171
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
172
|
+
|
|
173
|
+
describe('_resolveAtPrefix — @agent-name parsing', () => {
|
|
174
|
+
const agents = [
|
|
175
|
+
{ id: 'a1', name: 'Cody' },
|
|
176
|
+
{ id: 'a2', name: 'John Smith' },
|
|
177
|
+
{ id: 'a3', name: 'John' }, // prefix-conflict with #a2
|
|
178
|
+
{ id: 'a4', name: 'Multi Word Long Name' },
|
|
179
|
+
];
|
|
180
|
+
|
|
181
|
+
test('no @prefix → agentName null, messageText unchanged', () => {
|
|
182
|
+
const svc = makeService({ agents });
|
|
183
|
+
const r = svc._resolveAtPrefix('just a message', agents);
|
|
184
|
+
expect(r.agentName).toBeNull();
|
|
185
|
+
expect(r.matchedAgent).toBeNull();
|
|
186
|
+
expect(r.messageText).toBe('just a message');
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test('single-word name (legacy path)', () => {
|
|
190
|
+
const svc = makeService({ agents });
|
|
191
|
+
const r = svc._resolveAtPrefix('@Cody hello', agents);
|
|
192
|
+
expect(r.matchedAgent?.id).toBe('a1');
|
|
193
|
+
expect(r.messageText).toBe('hello');
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test('multi-word name with literal spaces works', () => {
|
|
197
|
+
const svc = makeService({ agents });
|
|
198
|
+
const r = svc._resolveAtPrefix('@John Smith write the report', agents);
|
|
199
|
+
expect(r.matchedAgent?.id).toBe('a2');
|
|
200
|
+
expect(r.agentName).toBe('John Smith');
|
|
201
|
+
expect(r.messageText).toBe('write the report');
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test('slug form @John_Smith resolves to "John Smith"', () => {
|
|
205
|
+
const svc = makeService({ agents });
|
|
206
|
+
const r = svc._resolveAtPrefix('@John_Smith write the report', agents);
|
|
207
|
+
expect(r.matchedAgent?.id).toBe('a2');
|
|
208
|
+
expect(r.messageText).toBe('write the report');
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test('dash form @John-Smith also works', () => {
|
|
212
|
+
const svc = makeService({ agents });
|
|
213
|
+
const r = svc._resolveAtPrefix('@John-Smith hi', agents);
|
|
214
|
+
expect(r.matchedAgent?.id).toBe('a2');
|
|
215
|
+
expect(r.messageText).toBe('hi');
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test('longest-match wins when one name is a prefix of another', () => {
|
|
219
|
+
// "@John Smith hello" must NOT resolve to the "John" agent.
|
|
220
|
+
const svc = makeService({ agents });
|
|
221
|
+
const r = svc._resolveAtPrefix('@John Smith hello', agents);
|
|
222
|
+
expect(r.matchedAgent?.id).toBe('a2');
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test('shorter name still wins when the longer name doesn\'t match', () => {
|
|
226
|
+
const svc = makeService({ agents });
|
|
227
|
+
const r = svc._resolveAtPrefix('@John hello', agents);
|
|
228
|
+
expect(r.matchedAgent?.id).toBe('a3');
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test('case-insensitive match', () => {
|
|
232
|
+
const svc = makeService({ agents });
|
|
233
|
+
const r = svc._resolveAtPrefix('@JOHN SMITH hi', agents);
|
|
234
|
+
expect(r.matchedAgent?.id).toBe('a2');
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test('extra whitespace between the name and the message is collapsed', () => {
|
|
238
|
+
const svc = makeService({ agents });
|
|
239
|
+
const r = svc._resolveAtPrefix('@Cody \t hello', agents);
|
|
240
|
+
expect(r.messageText).toBe('hello');
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test('partial-word collision does NOT false-match (Johnson should not match John)', () => {
|
|
244
|
+
const svc = makeService({ agents });
|
|
245
|
+
const r = svc._resolveAtPrefix('@Johnson hi', agents);
|
|
246
|
+
// Falls back to legacy capture so caller can show "not found"
|
|
247
|
+
expect(r.matchedAgent).toBeNull();
|
|
248
|
+
expect(r.agentName).toBe('Johnson');
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test('unknown @name → legacy capture for "not found" error', () => {
|
|
252
|
+
const svc = makeService({ agents });
|
|
253
|
+
const r = svc._resolveAtPrefix('@Stranger hi', agents);
|
|
254
|
+
expect(r.matchedAgent).toBeNull();
|
|
255
|
+
expect(r.agentName).toBe('Stranger');
|
|
256
|
+
expect(r.messageText).toBe('hi');
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
test('handles regex-sensitive characters in agent names safely', () => {
|
|
260
|
+
const dangerous = [{ id: 'a1', name: 'C++.parser' }];
|
|
261
|
+
const svc = makeService({ agents: dangerous });
|
|
262
|
+
const r = svc._resolveAtPrefix('@C++.parser go', dangerous);
|
|
263
|
+
expect(r.matchedAgent?.id).toBe('a1');
|
|
264
|
+
expect(r.messageText).toBe('go');
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
test('many-word names ("Multi Word Long Name") match end-to-end', () => {
|
|
268
|
+
const svc = makeService({ agents });
|
|
269
|
+
const r = svc._resolveAtPrefix('@Multi Word Long Name run the audit', agents);
|
|
270
|
+
expect(r.matchedAgent?.id).toBe('a4');
|
|
271
|
+
expect(r.messageText).toBe('run the audit');
|
|
272
|
+
});
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
276
|
+
// Multi-chat /start
|
|
277
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
278
|
+
|
|
279
|
+
describe('multi-chat /start', () => {
|
|
280
|
+
test('first chat /start registers; second chat /start ALSO registers (no global lock)', async () => {
|
|
281
|
+
const svc = makeService();
|
|
282
|
+
const bot = await connected(svc);
|
|
283
|
+
await bot.simulateMessage(userMsg(111, '/start'));
|
|
284
|
+
await bot.simulateMessage(userMsg(222, '/start'));
|
|
285
|
+
expect(svc.chats.has('111')).toBe(true);
|
|
286
|
+
expect(svc.chats.has('222')).toBe(true);
|
|
287
|
+
expect(svc.getStatus().chatCount).toBe(2);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
test('same chat /start twice does NOT duplicate state', async () => {
|
|
291
|
+
const svc = makeService();
|
|
292
|
+
const bot = await connected(svc);
|
|
293
|
+
await bot.simulateMessage(userMsg(111, '/start'));
|
|
294
|
+
await bot.simulateMessage(userMsg(111, '/start'));
|
|
295
|
+
expect(svc.chats.size).toBe(1);
|
|
296
|
+
// Second invocation should reply "Already connected".
|
|
297
|
+
const greetings = bot.calls.sendMessage.filter(c => String(c.chatId) === '111');
|
|
298
|
+
expect(greetings.length).toBeGreaterThanOrEqual(2);
|
|
299
|
+
expect(greetings[1].text).toMatch(/Already connected/);
|
|
300
|
+
});
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
304
|
+
// /logout, /reset, /new
|
|
305
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
306
|
+
|
|
307
|
+
describe('/logout', () => {
|
|
308
|
+
test('removes chat from registered set', async () => {
|
|
309
|
+
const svc = makeService();
|
|
310
|
+
const bot = await connected(svc);
|
|
311
|
+
await bot.simulateMessage(userMsg(111, '/start'));
|
|
312
|
+
await bot.simulateMessage(userMsg(111, '/logout'));
|
|
313
|
+
expect(svc.chats.has('111')).toBe(false);
|
|
314
|
+
});
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
describe('/reset', () => {
|
|
318
|
+
test('calls resetAgentConversation on the sticky agent', async () => {
|
|
319
|
+
const svc = makeService();
|
|
320
|
+
const bot = await connected(svc);
|
|
321
|
+
await bot.simulateMessage(userMsg(111, '/start'));
|
|
322
|
+
svc.chats.get('111').lastAgentId = 'a1';
|
|
323
|
+
svc.chats.get('111').activeAgentIds.add('a1');
|
|
324
|
+
|
|
325
|
+
await bot.simulateMessage(userMsg(111, '/reset'));
|
|
326
|
+
expect(svc.orchestrator.messageProcessor.resetAgentConversation).toHaveBeenCalledWith('a1');
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
test('returns friendly message when no sticky agent is set', async () => {
|
|
330
|
+
const svc = makeService();
|
|
331
|
+
const bot = await connected(svc);
|
|
332
|
+
await bot.simulateMessage(userMsg(111, '/start'));
|
|
333
|
+
bot.calls.sendMessage = [];
|
|
334
|
+
await bot.simulateMessage(userMsg(111, '/reset'));
|
|
335
|
+
const last = bot.calls.sendMessage[bot.calls.sendMessage.length - 1];
|
|
336
|
+
expect(last.text).toMatch(/No active agent/);
|
|
337
|
+
expect(svc.orchestrator.messageProcessor.resetAgentConversation).not.toHaveBeenCalled();
|
|
338
|
+
});
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
describe('/new <agent>', () => {
|
|
342
|
+
test('switches sticky agent + resets the new one', async () => {
|
|
343
|
+
const svc = makeService();
|
|
344
|
+
const bot = await connected(svc);
|
|
345
|
+
await bot.simulateMessage(userMsg(111, '/start'));
|
|
346
|
+
await bot.simulateMessage(userMsg(111, '/new Lina'));
|
|
347
|
+
expect(svc.chats.get('111').lastAgentId).toBe('a2');
|
|
348
|
+
expect(svc.orchestrator.messageProcessor.resetAgentConversation).toHaveBeenCalledWith('a2');
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
test('unknown agent returns error', async () => {
|
|
352
|
+
const svc = makeService();
|
|
353
|
+
const bot = await connected(svc);
|
|
354
|
+
await bot.simulateMessage(userMsg(111, '/start'));
|
|
355
|
+
bot.calls.sendMessage = [];
|
|
356
|
+
await bot.simulateMessage(userMsg(111, '/new Nobody'));
|
|
357
|
+
expect(bot.calls.sendMessage[0].text).toMatch(/not found/);
|
|
358
|
+
});
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
362
|
+
// End-to-end @-prefix with spaced name
|
|
363
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
364
|
+
|
|
365
|
+
describe('@-prefix dispatch — name with spaces', () => {
|
|
366
|
+
test('"@John Smith write the report" dispatches to the right agent with the right message', async () => {
|
|
367
|
+
const agents = [
|
|
368
|
+
{ id: 'js-1', name: 'John Smith', mode: 'chat', status: 'idle', currentModel: 'gpt-4.1' },
|
|
369
|
+
{ id: 'cody', name: 'Cody', mode: 'chat', status: 'idle', currentModel: 'gpt-4.1' },
|
|
370
|
+
];
|
|
371
|
+
const svc = makeService({ agents });
|
|
372
|
+
const bot = await connected(svc);
|
|
373
|
+
await bot.simulateMessage(userMsg(111, '/start'));
|
|
374
|
+
|
|
375
|
+
await bot.simulateMessage(userMsg(111, '@John Smith write the report'));
|
|
376
|
+
|
|
377
|
+
expect(svc.orchestrator.processRequest).toHaveBeenCalled();
|
|
378
|
+
const payload = svc.orchestrator.processRequest.mock.calls.slice(-1)[0][0].payload;
|
|
379
|
+
expect(payload.agentId).toBe('js-1');
|
|
380
|
+
expect(payload.message).toBe('write the report');
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
test('unknown @name surfaces a helpful "not found" reply, no orchestrator call', async () => {
|
|
384
|
+
const svc = makeService();
|
|
385
|
+
const bot = await connected(svc);
|
|
386
|
+
await bot.simulateMessage(userMsg(111, '/start'));
|
|
387
|
+
bot.calls.sendMessage = [];
|
|
388
|
+
|
|
389
|
+
await bot.simulateMessage(userMsg(111, '@Nobody hello'));
|
|
390
|
+
|
|
391
|
+
expect(svc.orchestrator.processRequest).not.toHaveBeenCalled();
|
|
392
|
+
const reply = bot.calls.sendMessage[0];
|
|
393
|
+
expect(reply.text).toMatch(/not found/);
|
|
394
|
+
// The friendly tip explaining slug syntax must be present so users
|
|
395
|
+
// who typed an underscore version learn the syntax. Underscores
|
|
396
|
+
// are MarkdownV2-escaped on the wire (`John\_Smith`), so allow
|
|
397
|
+
// any single character between the two halves.
|
|
398
|
+
expect(reply.text).toMatch(/John.Smith/);
|
|
399
|
+
});
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
403
|
+
// Group chat @mention gating
|
|
404
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
405
|
+
|
|
406
|
+
describe('group chat — @mention required', () => {
|
|
407
|
+
test('plain message in group is IGNORED (no orchestrator call)', async () => {
|
|
408
|
+
const svc = makeService();
|
|
409
|
+
const bot = await connected(svc);
|
|
410
|
+
await bot.simulateMessage(userMsg(-100, '/start', { chatType: 'supergroup', title: 'Ops' }));
|
|
411
|
+
svc.chats.get('-100').lastAgentId = 'a1';
|
|
412
|
+
svc.chats.get('-100').activeAgentIds.add('a1');
|
|
413
|
+
|
|
414
|
+
await bot.simulateMessage(userMsg(-100, 'hello team', { chatType: 'supergroup' }));
|
|
415
|
+
expect(svc.orchestrator.processRequest).not.toHaveBeenCalled();
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
test('@bot message in group strips mention and dispatches', async () => {
|
|
419
|
+
const svc = makeService();
|
|
420
|
+
const bot = await connected(svc);
|
|
421
|
+
await bot.simulateMessage(userMsg(-100, '/start', { chatType: 'supergroup', title: 'Ops' }));
|
|
422
|
+
svc.chats.get('-100').lastAgentId = 'a1';
|
|
423
|
+
svc.chats.get('-100').activeAgentIds.add('a1');
|
|
424
|
+
|
|
425
|
+
await bot.simulateMessage(userMsg(-100, '@loxia_bot summarize last meeting', { chatType: 'supergroup' }));
|
|
426
|
+
expect(svc.orchestrator.processRequest).toHaveBeenCalled();
|
|
427
|
+
const payload = svc.orchestrator.processRequest.mock.calls[0][0].payload;
|
|
428
|
+
expect(payload.message).toBe('summarize last meeting');
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
test('private chat does NOT require @mention', async () => {
|
|
432
|
+
const svc = makeService();
|
|
433
|
+
const bot = await connected(svc);
|
|
434
|
+
await bot.simulateMessage(userMsg(111, '/start'));
|
|
435
|
+
svc.chats.get('111').lastAgentId = 'a1';
|
|
436
|
+
svc.chats.get('111').activeAgentIds.add('a1');
|
|
437
|
+
|
|
438
|
+
await bot.simulateMessage(userMsg(111, 'hi there'));
|
|
439
|
+
expect(svc.orchestrator.processRequest).toHaveBeenCalled();
|
|
440
|
+
});
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
444
|
+
// Thinking placeholder & edit
|
|
445
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
446
|
+
|
|
447
|
+
describe('thinking placeholder', () => {
|
|
448
|
+
test('typing action + placeholder sent before orchestrator call', async () => {
|
|
449
|
+
const svc = makeService();
|
|
450
|
+
const bot = await connected(svc);
|
|
451
|
+
await bot.simulateMessage(userMsg(111, '/start'));
|
|
452
|
+
await bot.simulateMessage(userMsg(111, '@Cody help me'));
|
|
453
|
+
|
|
454
|
+
expect(bot.calls.sendChatAction).toContainEqual({ chatId: 111, action: 'typing' });
|
|
455
|
+
const thinking = bot.calls.sendMessage.find(c => c.text.includes('thinking'));
|
|
456
|
+
expect(thinking).toBeDefined();
|
|
457
|
+
expect(thinking.chatId).toBe(111);
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
test('placeholder is replaced (edit) when agent emits stream_complete', async () => {
|
|
461
|
+
const svc = makeService();
|
|
462
|
+
const bot = await connected(svc);
|
|
463
|
+
await bot.simulateMessage(userMsg(111, '/start'));
|
|
464
|
+
await bot.simulateMessage(userMsg(111, '@Cody hello'));
|
|
465
|
+
|
|
466
|
+
// Simulate broadcast.
|
|
467
|
+
await svc._handleBroadcastEvent({
|
|
468
|
+
type: 'stream_complete',
|
|
469
|
+
agentId: 'a1',
|
|
470
|
+
content: '<external>Sure — here you go.</external>',
|
|
471
|
+
role: 'assistant',
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
expect(bot.calls.editMessageText).toHaveLength(1);
|
|
475
|
+
expect(bot.calls.editMessageText[0].text).toMatch(/Cody/);
|
|
476
|
+
expect(bot.calls.editMessageText[0].text).toMatch(/Sure/);
|
|
477
|
+
});
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
481
|
+
// <actions> / <image> / <attachment> blocks
|
|
482
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
483
|
+
|
|
484
|
+
describe('rich reply blocks', () => {
|
|
485
|
+
test('<actions> block becomes inline keyboard with act:agentId:value callbacks', async () => {
|
|
486
|
+
const svc = makeService();
|
|
487
|
+
const bot = await connected(svc);
|
|
488
|
+
await bot.simulateMessage(userMsg(111, '/start'));
|
|
489
|
+
await bot.simulateMessage(userMsg(111, '@Cody what now'));
|
|
490
|
+
|
|
491
|
+
await svc._handleBroadcastEvent({
|
|
492
|
+
type: 'stream_complete', agentId: 'a1', role: 'assistant',
|
|
493
|
+
content:
|
|
494
|
+
'<external>Pick one:\n' +
|
|
495
|
+
'<actions>[{"label":"Continue","value":"go"},{"label":"Restart","value":"reset"}]</actions>' +
|
|
496
|
+
'</external>',
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
expect(bot.calls.editMessageText).toHaveLength(1);
|
|
500
|
+
const opts = bot.calls.editMessageText[0].opts;
|
|
501
|
+
const kb = opts.reply_markup.inline_keyboard;
|
|
502
|
+
expect(kb.map(row => row[0].text)).toEqual(['Continue', 'Restart']);
|
|
503
|
+
expect(kb[0][0].callback_data).toBe('act:a1:go');
|
|
504
|
+
expect(kb[1][0].callback_data).toBe('act:a1:reset');
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
test('button tap dispatches a synthetic agent message', async () => {
|
|
508
|
+
const svc = makeService();
|
|
509
|
+
const bot = await connected(svc);
|
|
510
|
+
await bot.simulateMessage(userMsg(111, '/start'));
|
|
511
|
+
svc.chats.get('111').activeAgentIds.add('a1');
|
|
512
|
+
svc.chats.get('111').lastAgentId = 'a1';
|
|
513
|
+
|
|
514
|
+
await bot.simulateCallback({
|
|
515
|
+
id: 'cb-1',
|
|
516
|
+
data: 'act:a1:keep going',
|
|
517
|
+
message: { chat: { id: 111, type: 'private' }, message_id: 999 },
|
|
518
|
+
from: { username: 'alice' },
|
|
519
|
+
});
|
|
520
|
+
expect(svc.orchestrator.processRequest).toHaveBeenCalled();
|
|
521
|
+
const payload = svc.orchestrator.processRequest.mock.calls.slice(-1)[0][0].payload;
|
|
522
|
+
expect(payload.message).toBe('keep going');
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
test('<image> block triggers sendPhoto', async () => {
|
|
526
|
+
const svc = makeService();
|
|
527
|
+
const bot = await connected(svc);
|
|
528
|
+
await bot.simulateMessage(userMsg(111, '/start'));
|
|
529
|
+
await bot.simulateMessage(userMsg(111, '@Cody chart'));
|
|
530
|
+
|
|
531
|
+
await svc._handleBroadcastEvent({
|
|
532
|
+
type: 'stream_complete', agentId: 'a1', role: 'assistant',
|
|
533
|
+
content: '<external>Here it is:\n<image src="https://example.com/chart.png">Q4 chart</image></external>',
|
|
534
|
+
});
|
|
535
|
+
expect(bot.calls.sendPhoto).toHaveLength(1);
|
|
536
|
+
expect(String(bot.calls.sendPhoto[0].chatId)).toBe('111');
|
|
537
|
+
expect(bot.calls.sendPhoto[0].photo).toBe('https://example.com/chart.png');
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
test('<attachment> block triggers sendDocument', async () => {
|
|
541
|
+
const svc = makeService();
|
|
542
|
+
const bot = await connected(svc);
|
|
543
|
+
await bot.simulateMessage(userMsg(111, '/start'));
|
|
544
|
+
await bot.simulateMessage(userMsg(111, '@Cody report'));
|
|
545
|
+
|
|
546
|
+
await svc._handleBroadcastEvent({
|
|
547
|
+
type: 'stream_complete', agentId: 'a1', role: 'assistant',
|
|
548
|
+
content: '<external>See report:\n<attachment src="/tmp/r.pdf" type="application/pdf">Report</attachment></external>',
|
|
549
|
+
});
|
|
550
|
+
expect(bot.calls.sendDocument).toHaveLength(1);
|
|
551
|
+
expect(String(bot.calls.sendDocument[0].chatId)).toBe('111');
|
|
552
|
+
expect(bot.calls.sendDocument[0].doc).toBe('/tmp/r.pdf');
|
|
553
|
+
});
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
557
|
+
// Always-relay errors / watch-gated completions
|
|
558
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
559
|
+
|
|
560
|
+
describe('error fan-out vs watch-gating', () => {
|
|
561
|
+
test('agent_error relays to a chat that has NOT subscribed via /watch', async () => {
|
|
562
|
+
const svc = makeService();
|
|
563
|
+
const bot = await connected(svc);
|
|
564
|
+
await bot.simulateMessage(userMsg(111, '/start'));
|
|
565
|
+
expect(svc.chats.get('111').watchEnabled).toBe(false);
|
|
566
|
+
|
|
567
|
+
await svc._handleBroadcastEvent({
|
|
568
|
+
type: 'agent_error', agentId: 'a1', agentName: 'Cody', message: 'boom',
|
|
569
|
+
});
|
|
570
|
+
const sent = bot.calls.sendMessage.find(c => c.text.includes('Agent Error'));
|
|
571
|
+
expect(sent).toBeDefined();
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
test('agent_error dedup: identical errors within the window only send once', async () => {
|
|
575
|
+
const svc = makeService();
|
|
576
|
+
const bot = await connected(svc);
|
|
577
|
+
await bot.simulateMessage(userMsg(111, '/start'));
|
|
578
|
+
|
|
579
|
+
const payload = { type: 'agent_error', agentId: 'a1', agentName: 'Cody', message: 'boom' };
|
|
580
|
+
await svc._handleBroadcastEvent(payload);
|
|
581
|
+
await svc._handleBroadcastEvent(payload);
|
|
582
|
+
await svc._handleBroadcastEvent(payload);
|
|
583
|
+
await svc._handleBroadcastEvent(payload);
|
|
584
|
+
|
|
585
|
+
const errorMessages = bot.calls.sendMessage.filter(c => c.text.includes('Agent Error'));
|
|
586
|
+
// Only the first one should have made it through; the next three are
|
|
587
|
+
// counted but suppressed.
|
|
588
|
+
expect(errorMessages).toHaveLength(1);
|
|
589
|
+
// Dedup state confirms three were absorbed.
|
|
590
|
+
const entry = [...svc._errorBroadcastDedup.values()][0];
|
|
591
|
+
expect(entry?.count).toBe(4);
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
test('agent_error dedup: different agents do NOT collide', async () => {
|
|
595
|
+
const svc = makeService();
|
|
596
|
+
const bot = await connected(svc);
|
|
597
|
+
await bot.simulateMessage(userMsg(111, '/start'));
|
|
598
|
+
|
|
599
|
+
await svc._handleBroadcastEvent({ type: 'agent_error', agentId: 'a1', agentName: 'Cody', message: 'same' });
|
|
600
|
+
await svc._handleBroadcastEvent({ type: 'agent_error', agentId: 'a2', agentName: 'Carol', message: 'same' });
|
|
601
|
+
|
|
602
|
+
const errorMessages = bot.calls.sendMessage.filter(c => c.text.includes('Agent Error'));
|
|
603
|
+
expect(errorMessages).toHaveLength(2);
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
test('agent_error dedup: textual variations of same error coalesce', async () => {
|
|
607
|
+
const svc = makeService();
|
|
608
|
+
const bot = await connected(svc);
|
|
609
|
+
await bot.simulateMessage(userMsg(111, '/start'));
|
|
610
|
+
|
|
611
|
+
// Same error, formatted slightly differently — should dedup.
|
|
612
|
+
await svc._handleBroadcastEvent({ type: 'agent_error', agentId: 'a1', message: 'Connection refused' });
|
|
613
|
+
await svc._handleBroadcastEvent({ type: 'agent_error', agentId: 'a1', message: 'connection refused' });
|
|
614
|
+
await svc._handleBroadcastEvent({ type: 'agent_error', agentId: 'a1', message: ' Connection refused ' });
|
|
615
|
+
|
|
616
|
+
const errorMessages = bot.calls.sendMessage.filter(c => c.text.includes('Agent Error'));
|
|
617
|
+
expect(errorMessages).toHaveLength(1);
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
test('agent_error dedup: window close emits a "still happening" summary when count > 1', async () => {
|
|
621
|
+
jest.useFakeTimers();
|
|
622
|
+
try {
|
|
623
|
+
const svc = makeService();
|
|
624
|
+
const bot = await connected(svc);
|
|
625
|
+
await bot.simulateMessage(userMsg(111, '/start'));
|
|
626
|
+
|
|
627
|
+
// First fires immediately, plus two suppressed.
|
|
628
|
+
await svc._handleBroadcastEvent({ type: 'agent_error', agentId: 'a1', agentName: 'Cody', message: 'boom' });
|
|
629
|
+
await svc._handleBroadcastEvent({ type: 'agent_error', agentId: 'a1', agentName: 'Cody', message: 'boom' });
|
|
630
|
+
await svc._handleBroadcastEvent({ type: 'agent_error', agentId: 'a1', agentName: 'Cody', message: 'boom' });
|
|
631
|
+
|
|
632
|
+
// Advance past the 5-minute dedup window.
|
|
633
|
+
jest.advanceTimersByTime(5 * 60 * 1000 + 100);
|
|
634
|
+
// Let the timer callback's microtasks resolve.
|
|
635
|
+
await Promise.resolve();
|
|
636
|
+
await Promise.resolve();
|
|
637
|
+
|
|
638
|
+
const summary = bot.calls.sendMessage.find(c => c.text.includes('still happening'));
|
|
639
|
+
expect(summary).toBeDefined();
|
|
640
|
+
expect(summary.text).toMatch(/2 more/);
|
|
641
|
+
} finally {
|
|
642
|
+
jest.useRealTimers();
|
|
643
|
+
}
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
test('agent_error dedup: a single error does NOT trigger a summary', async () => {
|
|
647
|
+
jest.useFakeTimers();
|
|
648
|
+
try {
|
|
649
|
+
const svc = makeService();
|
|
650
|
+
const bot = await connected(svc);
|
|
651
|
+
await bot.simulateMessage(userMsg(111, '/start'));
|
|
652
|
+
|
|
653
|
+
await svc._handleBroadcastEvent({ type: 'agent_error', agentId: 'a1', message: 'one-off' });
|
|
654
|
+
jest.advanceTimersByTime(5 * 60 * 1000 + 100);
|
|
655
|
+
await Promise.resolve();
|
|
656
|
+
|
|
657
|
+
// The original message went out. No follow-up summary.
|
|
658
|
+
const summary = bot.calls.sendMessage.find(c => c.text.includes('still happening'));
|
|
659
|
+
expect(summary).toBeUndefined();
|
|
660
|
+
} finally {
|
|
661
|
+
jest.useRealTimers();
|
|
662
|
+
}
|
|
663
|
+
});
|
|
664
|
+
|
|
665
|
+
test('execution_stopped does NOT relay unless /watch is on', async () => {
|
|
666
|
+
const svc = makeService();
|
|
667
|
+
const bot = await connected(svc);
|
|
668
|
+
await bot.simulateMessage(userMsg(111, '/start'));
|
|
669
|
+
|
|
670
|
+
await svc._handleBroadcastEvent({
|
|
671
|
+
type: 'execution_stopped', agentId: 'a1', agentName: 'Cody', message: 'done',
|
|
672
|
+
});
|
|
673
|
+
// Notifications are batched on a timer, so we just assert nothing
|
|
674
|
+
// queued at the immediate level.
|
|
675
|
+
expect(bot.calls.sendMessage.find(c => c.text.includes('Agent Finished'))).toBeUndefined();
|
|
676
|
+
});
|
|
677
|
+
|
|
678
|
+
test('execution_stopped relays when /watch is on', async () => {
|
|
679
|
+
jest.useFakeTimers();
|
|
680
|
+
try {
|
|
681
|
+
const svc = makeService();
|
|
682
|
+
const bot = await connected(svc);
|
|
683
|
+
await bot.simulateMessage(userMsg(111, '/start'));
|
|
684
|
+
await bot.simulateMessage(userMsg(111, '/watch'));
|
|
685
|
+
|
|
686
|
+
await svc._handleBroadcastEvent({
|
|
687
|
+
type: 'execution_stopped', agentId: 'a1', agentName: 'Cody', message: 'done',
|
|
688
|
+
});
|
|
689
|
+
jest.advanceTimersByTime(11000); // flush batch interval (10s)
|
|
690
|
+
await Promise.resolve(); // microtask drain
|
|
691
|
+
// Sent messages are async — check that the queue WOULD have been flushed.
|
|
692
|
+
expect(svc.notificationQueue.size === 0 || bot.calls.sendMessage.some(c => c.text.includes('Agent Finished')))
|
|
693
|
+
.toBe(true);
|
|
694
|
+
} finally {
|
|
695
|
+
jest.useRealTimers();
|
|
696
|
+
}
|
|
697
|
+
});
|
|
698
|
+
});
|
|
699
|
+
|
|
700
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
701
|
+
// Pagination
|
|
702
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
703
|
+
|
|
704
|
+
describe('paginated /agents', () => {
|
|
705
|
+
test('first page only shows PAGE_SIZE entries when many agents exist', async () => {
|
|
706
|
+
const many = Array.from({ length: 20 }, (_, i) => ({
|
|
707
|
+
id: `a${i}`, name: `Agent${i}`, mode: 'chat', status: 'idle',
|
|
708
|
+
}));
|
|
709
|
+
const svc = makeService({ agents: many });
|
|
710
|
+
const bot = await connected(svc);
|
|
711
|
+
await bot.simulateMessage(userMsg(111, '/start'));
|
|
712
|
+
bot.calls.sendMessage = [];
|
|
713
|
+
await bot.simulateMessage(userMsg(111, '/agents'));
|
|
714
|
+
|
|
715
|
+
const listMsg = bot.calls.sendMessage.find(c => c.text.includes('Agents (20)'));
|
|
716
|
+
expect(listMsg).toBeDefined();
|
|
717
|
+
// Page 1/3 of 20 with PAGE_SIZE=8 → 8 entries shown.
|
|
718
|
+
const buttons = listMsg.opts.reply_markup.inline_keyboard;
|
|
719
|
+
expect(buttons.filter(row => row[0].callback_data?.startsWith('agent_detail:'))).toHaveLength(8);
|
|
720
|
+
// Last row is the pagination nav.
|
|
721
|
+
const nav = buttons[buttons.length - 1];
|
|
722
|
+
expect(nav.some(b => b.callback_data?.startsWith('agents_page:'))).toBe(true);
|
|
723
|
+
});
|
|
724
|
+
});
|
|
725
|
+
|
|
726
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
727
|
+
// Markdown vs HTML vs document selection
|
|
728
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
729
|
+
|
|
730
|
+
describe('text formatting selection', () => {
|
|
731
|
+
test('plain text uses MarkdownV2', () => {
|
|
732
|
+
const svc = makeService();
|
|
733
|
+
const out = svc._formatAgentBody('hello world.');
|
|
734
|
+
expect(out.parseMode).toBe('MarkdownV2');
|
|
735
|
+
expect(out.body).toContain('hello world\\.'); // dot escaped
|
|
736
|
+
});
|
|
737
|
+
|
|
738
|
+
test('short fenced code uses HTML', () => {
|
|
739
|
+
const svc = makeService();
|
|
740
|
+
const out = svc._formatAgentBody('See:\n```js\nconst x = 1;\n```');
|
|
741
|
+
expect(out.parseMode).toBe('HTML');
|
|
742
|
+
expect(out.body).toMatch(/<pre>const x = 1;/);
|
|
743
|
+
});
|
|
744
|
+
|
|
745
|
+
test('long fenced code (> threshold) is sent as document', () => {
|
|
746
|
+
const svc = makeService();
|
|
747
|
+
const big = 'x = 1;\n'.repeat(800);
|
|
748
|
+
const out = svc._formatAgentBody('```py\n' + big + '\n```');
|
|
749
|
+
expect(out.kind).toBe('document');
|
|
750
|
+
});
|
|
751
|
+
});
|
|
752
|
+
|
|
753
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
754
|
+
// Config migration
|
|
755
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
756
|
+
|
|
757
|
+
describe('config migration', () => {
|
|
758
|
+
test('legacy { chatId: "x", watchEnabled: true } loads as a single-chat entry', async () => {
|
|
759
|
+
virtualFs.content = JSON.stringify({ chatId: 'X1', watchEnabled: true, botToken: 'T' });
|
|
760
|
+
const svc = makeService();
|
|
761
|
+
await svc._loadConfig();
|
|
762
|
+
expect(svc.chats.has('X1')).toBe(true);
|
|
763
|
+
expect(svc.chats.get('X1').watchEnabled).toBe(true);
|
|
764
|
+
});
|
|
765
|
+
|
|
766
|
+
test('legacy fields are dropped on next save', async () => {
|
|
767
|
+
virtualFs.content = JSON.stringify({ chatId: 'X1', watchEnabled: true, botToken: 'T' });
|
|
768
|
+
const svc = makeService();
|
|
769
|
+
await svc._loadConfig();
|
|
770
|
+
await svc._saveConfig();
|
|
771
|
+
const persisted = JSON.parse(virtualFs.content);
|
|
772
|
+
expect(persisted.chatId).toBeUndefined();
|
|
773
|
+
expect(persisted.watchEnabled).toBeUndefined();
|
|
774
|
+
expect(persisted.chats).toHaveLength(1);
|
|
775
|
+
expect(persisted.chats[0].chatId).toBe('X1');
|
|
776
|
+
});
|
|
777
|
+
});
|
|
778
|
+
|
|
779
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
780
|
+
// Bridged channels per-chat
|
|
781
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
782
|
+
|
|
783
|
+
describe('getBridgedChannels', () => {
|
|
784
|
+
test('returns one alias per chat the agent is active in, plus a bare "telegram" alias', async () => {
|
|
785
|
+
const svc = makeService();
|
|
786
|
+
const bot = await connected(svc);
|
|
787
|
+
await bot.simulateMessage(userMsg(111, '/start'));
|
|
788
|
+
await bot.simulateMessage(userMsg(222, '/start'));
|
|
789
|
+
svc.chats.get('111').activeAgentIds.add('a1');
|
|
790
|
+
svc.chats.get('222').activeAgentIds.add('a1');
|
|
791
|
+
|
|
792
|
+
const bridged = svc.getBridgedChannels('a1');
|
|
793
|
+
const aliases = bridged.map(c => c.alias);
|
|
794
|
+
expect(aliases).toEqual(expect.arrayContaining([
|
|
795
|
+
'telegram:chat-111',
|
|
796
|
+
'telegram:chat-222',
|
|
797
|
+
'telegram',
|
|
798
|
+
]));
|
|
799
|
+
});
|
|
800
|
+
|
|
801
|
+
test('returns empty array when agent has no chat', async () => {
|
|
802
|
+
const svc = makeService();
|
|
803
|
+
await connected(svc);
|
|
804
|
+
expect(svc.getBridgedChannels('a1')).toEqual([]);
|
|
805
|
+
});
|
|
806
|
+
});
|
|
807
|
+
|
|
808
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
809
|
+
// imageGenerated → Telegram relay
|
|
810
|
+
//
|
|
811
|
+
// Background: the imageTool emits a separate broadcast (`imageGenerated`)
|
|
812
|
+
// alongside the agent's textual `stream_complete`. Before this fix the
|
|
813
|
+
// `imageGenerated` event was silently dropped and the user — who'd
|
|
814
|
+
// asked the bot to "draw me X" — saw no image come back to the chat.
|
|
815
|
+
//
|
|
816
|
+
// Behavior we lock in:
|
|
817
|
+
// - imageGenerated is buffered per agent, flushed on the next
|
|
818
|
+
// stream_complete for the same agent into every active chat that
|
|
819
|
+
// has that agent linked.
|
|
820
|
+
// - dedupes against images the agent already embedded as
|
|
821
|
+
// `<image src="…">` markup so we don't double-post.
|
|
822
|
+
// - works even when the agent's text reply has no `<external>` block
|
|
823
|
+
// at all (image-only turn).
|
|
824
|
+
// - skips chats where the agent isn't active (multi-chat isolation).
|
|
825
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
826
|
+
|
|
827
|
+
describe('imageGenerated relay', () => {
|
|
828
|
+
test('imageGenerated buffered, flushed on stream_complete into the active chat', async () => {
|
|
829
|
+
const svc = makeService();
|
|
830
|
+
const bot = await connected(svc);
|
|
831
|
+
await bot.simulateMessage(userMsg(111, '/start'));
|
|
832
|
+
await bot.simulateMessage(userMsg(111, '@Cody draw a sunset'));
|
|
833
|
+
|
|
834
|
+
// Image arrives from the tool first…
|
|
835
|
+
await svc._handleBroadcastEvent({
|
|
836
|
+
type: 'imageGenerated', agentId: 'a1',
|
|
837
|
+
imageUrl: 'https://cdn.example/sunset.png',
|
|
838
|
+
prompt: 'a sunset over Paris',
|
|
839
|
+
success: true,
|
|
840
|
+
});
|
|
841
|
+
// Bot should not have posted the photo yet — we wait for the
|
|
842
|
+
// agent's text completion to keep the order stable.
|
|
843
|
+
expect(bot.calls.sendPhoto).toHaveLength(0);
|
|
844
|
+
|
|
845
|
+
// Then the agent's text completion arrives.
|
|
846
|
+
await svc._handleBroadcastEvent({
|
|
847
|
+
type: 'stream_complete', agentId: 'a1', role: 'assistant',
|
|
848
|
+
content: '<external>Here is your sunset.</external>',
|
|
849
|
+
});
|
|
850
|
+
|
|
851
|
+
expect(bot.calls.sendPhoto).toHaveLength(1);
|
|
852
|
+
expect(bot.calls.sendPhoto[0].photo).toBe('https://cdn.example/sunset.png');
|
|
853
|
+
expect(String(bot.calls.sendPhoto[0].chatId)).toBe('111');
|
|
854
|
+
});
|
|
855
|
+
|
|
856
|
+
test('still flushes even when stream_complete has no <external> block', async () => {
|
|
857
|
+
const svc = makeService();
|
|
858
|
+
const bot = await connected(svc);
|
|
859
|
+
await bot.simulateMessage(userMsg(111, '/start'));
|
|
860
|
+
await bot.simulateMessage(userMsg(111, '@Cody draw'));
|
|
861
|
+
|
|
862
|
+
await svc._handleBroadcastEvent({
|
|
863
|
+
type: 'imageGenerated', agentId: 'a1',
|
|
864
|
+
imageUrl: 'https://cdn.example/only.png',
|
|
865
|
+
success: true,
|
|
866
|
+
});
|
|
867
|
+
await svc._handleBroadcastEvent({
|
|
868
|
+
type: 'stream_complete', agentId: 'a1', role: 'assistant',
|
|
869
|
+
content: 'internal-only narration, no external block',
|
|
870
|
+
});
|
|
871
|
+
expect(bot.calls.sendPhoto).toHaveLength(1);
|
|
872
|
+
expect(bot.calls.sendPhoto[0].photo).toBe('https://cdn.example/only.png');
|
|
873
|
+
});
|
|
874
|
+
|
|
875
|
+
test('deduplicates against images the agent already embedded in <external>', async () => {
|
|
876
|
+
const svc = makeService();
|
|
877
|
+
const bot = await connected(svc);
|
|
878
|
+
await bot.simulateMessage(userMsg(111, '/start'));
|
|
879
|
+
await bot.simulateMessage(userMsg(111, '@Cody draw'));
|
|
880
|
+
|
|
881
|
+
const url = 'https://cdn.example/dup.png';
|
|
882
|
+
await svc._handleBroadcastEvent({
|
|
883
|
+
type: 'imageGenerated', agentId: 'a1', imageUrl: url, success: true,
|
|
884
|
+
});
|
|
885
|
+
await svc._handleBroadcastEvent({
|
|
886
|
+
type: 'stream_complete', agentId: 'a1', role: 'assistant',
|
|
887
|
+
content: `<external>See:\n<image src="${url}">my pic</image></external>`,
|
|
888
|
+
});
|
|
889
|
+
expect(bot.calls.sendPhoto).toHaveLength(1);
|
|
890
|
+
});
|
|
891
|
+
|
|
892
|
+
test('does NOT leak the image to a chat where the agent isn\'t active', async () => {
|
|
893
|
+
const svc = makeService();
|
|
894
|
+
const bot = await connected(svc);
|
|
895
|
+
await bot.simulateMessage(userMsg(111, '/start'));
|
|
896
|
+
await bot.simulateMessage(userMsg(222, '/start'));
|
|
897
|
+
await bot.simulateMessage(userMsg(111, '@Cody draw'));
|
|
898
|
+
// Chat 222 never @-mentioned the agent → activeAgentIds is empty.
|
|
899
|
+
expect(svc.chats.get('222').activeAgentIds.has('a1')).toBe(false);
|
|
900
|
+
|
|
901
|
+
await svc._handleBroadcastEvent({
|
|
902
|
+
type: 'imageGenerated', agentId: 'a1',
|
|
903
|
+
imageUrl: 'https://cdn.example/private.png',
|
|
904
|
+
success: true,
|
|
905
|
+
});
|
|
906
|
+
await svc._handleBroadcastEvent({
|
|
907
|
+
type: 'stream_complete', agentId: 'a1', role: 'assistant',
|
|
908
|
+
content: '<external>done</external>',
|
|
909
|
+
});
|
|
910
|
+
|
|
911
|
+
const chats = bot.calls.sendPhoto.map(c => String(c.chatId));
|
|
912
|
+
expect(chats).toEqual(['111']);
|
|
913
|
+
});
|
|
914
|
+
|
|
915
|
+
test('buffer drops stale entries (>5 min) on next insert', async () => {
|
|
916
|
+
// Direct unit test of the buffer helper — bypasses the
|
|
917
|
+
// `_handleBroadcastEvent` early-return guard (which requires a
|
|
918
|
+
// connected bot + a /start'ed chat) so we can focus on the TTL.
|
|
919
|
+
const svc = makeService();
|
|
920
|
+
const original = Date.now;
|
|
921
|
+
try {
|
|
922
|
+
let now = 1_000_000_000_000;
|
|
923
|
+
Date.now = () => now;
|
|
924
|
+
|
|
925
|
+
svc._bufferGeneratedImage({ agentId: 'a1', imageUrl: 'https://cdn.example/stale.png' });
|
|
926
|
+
now += 6 * 60 * 1000; // 6 min later
|
|
927
|
+
svc._bufferGeneratedImage({ agentId: 'a1', imageUrl: 'https://cdn.example/fresh.png' });
|
|
928
|
+
const remaining = svc._pendingImagesByAgent.get('a1');
|
|
929
|
+
expect(remaining.map(e => e.imageUrl)).toEqual(['https://cdn.example/fresh.png']);
|
|
930
|
+
} finally {
|
|
931
|
+
Date.now = original;
|
|
932
|
+
}
|
|
933
|
+
});
|
|
934
|
+
|
|
935
|
+
test('imageGenerated with no imageUrl is ignored (no crash)', () => {
|
|
936
|
+
const svc = makeService();
|
|
937
|
+
svc._bufferGeneratedImage({ agentId: 'a1' });
|
|
938
|
+
expect(svc._pendingImagesByAgent.has('a1')).toBe(false);
|
|
939
|
+
});
|
|
940
|
+
});
|
|
941
|
+
|