@otto-assistant/bridge 0.4.92
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin.js +2 -0
- package/dist/agent-model.e2e.test.js +755 -0
- package/dist/ai-tool-to-genai.js +233 -0
- package/dist/ai-tool-to-genai.test.js +267 -0
- package/dist/ai-tool.js +6 -0
- package/dist/anthropic-auth-plugin.js +728 -0
- package/dist/anthropic-auth-plugin.test.js +125 -0
- package/dist/anthropic-auth-state.js +231 -0
- package/dist/bin.js +90 -0
- package/dist/channel-management.js +227 -0
- package/dist/cli-parsing.test.js +137 -0
- package/dist/cli-send-thread.e2e.test.js +356 -0
- package/dist/cli.js +3276 -0
- package/dist/commands/abort.js +65 -0
- package/dist/commands/action-buttons.js +245 -0
- package/dist/commands/add-project.js +113 -0
- package/dist/commands/agent.js +335 -0
- package/dist/commands/ask-question.js +274 -0
- package/dist/commands/btw.js +116 -0
- package/dist/commands/compact.js +120 -0
- package/dist/commands/context-usage.js +140 -0
- package/dist/commands/create-new-project.js +130 -0
- package/dist/commands/diff.js +63 -0
- package/dist/commands/file-upload.js +275 -0
- package/dist/commands/fork.js +220 -0
- package/dist/commands/gemini-apikey.js +70 -0
- package/dist/commands/login.js +885 -0
- package/dist/commands/mcp.js +239 -0
- package/dist/commands/memory-snapshot.js +24 -0
- package/dist/commands/mention-mode.js +44 -0
- package/dist/commands/merge-worktree.js +159 -0
- package/dist/commands/model-variant.js +364 -0
- package/dist/commands/model.js +776 -0
- package/dist/commands/new-worktree.js +366 -0
- package/dist/commands/paginated-select.js +57 -0
- package/dist/commands/permissions.js +274 -0
- package/dist/commands/queue.js +206 -0
- package/dist/commands/remove-project.js +115 -0
- package/dist/commands/restart-opencode-server.js +127 -0
- package/dist/commands/resume.js +149 -0
- package/dist/commands/run-command.js +79 -0
- package/dist/commands/screenshare.js +303 -0
- package/dist/commands/screenshare.test.js +20 -0
- package/dist/commands/session-id.js +78 -0
- package/dist/commands/session.js +176 -0
- package/dist/commands/share.js +80 -0
- package/dist/commands/tasks.js +205 -0
- package/dist/commands/types.js +2 -0
- package/dist/commands/undo-redo.js +305 -0
- package/dist/commands/unset-model.js +138 -0
- package/dist/commands/upgrade.js +42 -0
- package/dist/commands/user-command.js +155 -0
- package/dist/commands/verbosity.js +125 -0
- package/dist/commands/worktree-settings.js +43 -0
- package/dist/commands/worktrees.js +410 -0
- package/dist/condense-memory.js +33 -0
- package/dist/config.js +94 -0
- package/dist/context-awareness-plugin.js +363 -0
- package/dist/context-awareness-plugin.test.js +124 -0
- package/dist/critique-utils.js +95 -0
- package/dist/database.js +1310 -0
- package/dist/db.js +251 -0
- package/dist/db.test.js +138 -0
- package/dist/debounce-timeout.js +28 -0
- package/dist/debounced-process-flush.js +77 -0
- package/dist/discord-bot.js +1008 -0
- package/dist/discord-command-registration.js +524 -0
- package/dist/discord-urls.js +81 -0
- package/dist/discord-utils.js +591 -0
- package/dist/discord-utils.test.js +134 -0
- package/dist/errors.js +157 -0
- package/dist/escape-backticks.test.js +429 -0
- package/dist/event-stream-real-capture.e2e.test.js +533 -0
- package/dist/eventsource-parser.test.js +327 -0
- package/dist/exec-async.js +26 -0
- package/dist/external-opencode-sync.js +480 -0
- package/dist/format-tables.js +302 -0
- package/dist/format-tables.test.js +308 -0
- package/dist/forum-sync/config.js +79 -0
- package/dist/forum-sync/discord-operations.js +154 -0
- package/dist/forum-sync/index.js +5 -0
- package/dist/forum-sync/markdown.js +113 -0
- package/dist/forum-sync/sync-to-discord.js +417 -0
- package/dist/forum-sync/sync-to-files.js +190 -0
- package/dist/forum-sync/types.js +53 -0
- package/dist/forum-sync/watchers.js +307 -0
- package/dist/gateway-proxy-reconnect.e2e.test.js +394 -0
- package/dist/gateway-proxy.e2e.test.js +483 -0
- package/dist/genai-worker-wrapper.js +111 -0
- package/dist/genai-worker.js +311 -0
- package/dist/genai.js +232 -0
- package/dist/generated/browser.js +17 -0
- package/dist/generated/client.js +37 -0
- package/dist/generated/commonInputTypes.js +10 -0
- package/dist/generated/enums.js +52 -0
- package/dist/generated/internal/class.js +49 -0
- package/dist/generated/internal/prismaNamespace.js +253 -0
- package/dist/generated/internal/prismaNamespaceBrowser.js +223 -0
- package/dist/generated/models/bot_api_keys.js +1 -0
- package/dist/generated/models/bot_tokens.js +1 -0
- package/dist/generated/models/channel_agents.js +1 -0
- package/dist/generated/models/channel_directories.js +1 -0
- package/dist/generated/models/channel_mention_mode.js +1 -0
- package/dist/generated/models/channel_models.js +1 -0
- package/dist/generated/models/channel_verbosity.js +1 -0
- package/dist/generated/models/channel_worktrees.js +1 -0
- package/dist/generated/models/forum_sync_configs.js +1 -0
- package/dist/generated/models/global_models.js +1 -0
- package/dist/generated/models/ipc_requests.js +1 -0
- package/dist/generated/models/part_messages.js +1 -0
- package/dist/generated/models/scheduled_tasks.js +1 -0
- package/dist/generated/models/session_agents.js +1 -0
- package/dist/generated/models/session_events.js +1 -0
- package/dist/generated/models/session_models.js +1 -0
- package/dist/generated/models/session_start_sources.js +1 -0
- package/dist/generated/models/thread_sessions.js +1 -0
- package/dist/generated/models/thread_worktrees.js +1 -0
- package/dist/generated/models.js +1 -0
- package/dist/heap-monitor.js +122 -0
- package/dist/hrana-server.js +263 -0
- package/dist/hrana-server.test.js +370 -0
- package/dist/html-actions.js +123 -0
- package/dist/html-actions.test.js +70 -0
- package/dist/html-components.js +117 -0
- package/dist/html-components.test.js +34 -0
- package/dist/image-optimizer-plugin.js +153 -0
- package/dist/image-utils.js +112 -0
- package/dist/interaction-handler.js +397 -0
- package/dist/ipc-polling.js +252 -0
- package/dist/ipc-tools-plugin.js +193 -0
- package/dist/kimaki-digital-twin.e2e.test.js +161 -0
- package/dist/kimaki-opencode-plugin-loading.e2e.test.js +87 -0
- package/dist/kimaki-opencode-plugin.js +17 -0
- package/dist/kimaki-opencode-plugin.test.js +98 -0
- package/dist/limit-heading-depth.js +25 -0
- package/dist/limit-heading-depth.test.js +105 -0
- package/dist/logger.js +165 -0
- package/dist/markdown.js +342 -0
- package/dist/markdown.test.js +257 -0
- package/dist/message-finish-field.e2e.test.js +165 -0
- package/dist/message-formatting.js +413 -0
- package/dist/message-formatting.test.js +73 -0
- package/dist/message-preprocessing.js +330 -0
- package/dist/onboarding-tutorial.js +172 -0
- package/dist/onboarding-welcome.js +37 -0
- package/dist/openai-realtime.js +224 -0
- package/dist/opencode-command-detection.js +65 -0
- package/dist/opencode-command-detection.test.js +240 -0
- package/dist/opencode-command.js +129 -0
- package/dist/opencode-command.test.js +48 -0
- package/dist/opencode-interrupt-plugin.js +361 -0
- package/dist/opencode-interrupt-plugin.test.js +458 -0
- package/dist/opencode.js +861 -0
- package/dist/otto/branding.js +22 -0
- package/dist/otto/index.js +21 -0
- package/dist/parse-permission-rules.test.js +117 -0
- package/dist/patch-text-parser.js +97 -0
- package/dist/plugin-logger.js +59 -0
- package/dist/privacy-sanitizer.js +105 -0
- package/dist/queue-advanced-abort.e2e.test.js +293 -0
- package/dist/queue-advanced-action-buttons.e2e.test.js +206 -0
- package/dist/queue-advanced-e2e-setup.js +786 -0
- package/dist/queue-advanced-footer.e2e.test.js +472 -0
- package/dist/queue-advanced-model-switch.e2e.test.js +299 -0
- package/dist/queue-advanced-permissions-typing.e2e.test.js +180 -0
- package/dist/queue-advanced-question.e2e.test.js +261 -0
- package/dist/queue-advanced-typing-interrupt.e2e.test.js +114 -0
- package/dist/queue-advanced-typing.e2e.test.js +153 -0
- package/dist/queue-drain-after-interactive-ui.e2e.test.js +119 -0
- package/dist/queue-interrupt-drain.e2e.test.js +135 -0
- package/dist/queue-question-select-drain.e2e.test.js +120 -0
- package/dist/runtime-idle-sweeper.js +52 -0
- package/dist/runtime-lifecycle.e2e.test.js +508 -0
- package/dist/sentry.js +23 -0
- package/dist/session-handler/agent-utils.js +67 -0
- package/dist/session-handler/event-stream-state.js +420 -0
- package/dist/session-handler/event-stream-state.test.js +563 -0
- package/dist/session-handler/model-utils.js +124 -0
- package/dist/session-handler/opencode-session-event-log.js +94 -0
- package/dist/session-handler/thread-runtime-state.js +104 -0
- package/dist/session-handler/thread-session-runtime.js +3258 -0
- package/dist/session-handler.js +9 -0
- package/dist/session-search.js +100 -0
- package/dist/session-search.test.js +40 -0
- package/dist/session-title-rename.test.js +80 -0
- package/dist/startup-service.js +153 -0
- package/dist/startup-time.e2e.test.js +296 -0
- package/dist/store.js +17 -0
- package/dist/system-message.js +613 -0
- package/dist/system-message.test.js +602 -0
- package/dist/task-runner.js +295 -0
- package/dist/task-schedule.js +209 -0
- package/dist/task-schedule.test.js +71 -0
- package/dist/test-utils.js +299 -0
- package/dist/thinking-utils.js +35 -0
- package/dist/thread-message-queue.e2e.test.js +999 -0
- package/dist/tools.js +357 -0
- package/dist/undo-redo.e2e.test.js +161 -0
- package/dist/unnest-code-blocks.js +146 -0
- package/dist/unnest-code-blocks.test.js +673 -0
- package/dist/upgrade.js +114 -0
- package/dist/utils.js +144 -0
- package/dist/voice-attachment.js +34 -0
- package/dist/voice-handler.js +646 -0
- package/dist/voice-message.e2e.test.js +1021 -0
- package/dist/voice.js +447 -0
- package/dist/voice.test.js +235 -0
- package/dist/wait-session.js +94 -0
- package/dist/websockify.js +69 -0
- package/dist/worker-types.js +4 -0
- package/dist/worktree-lifecycle.e2e.test.js +308 -0
- package/dist/worktree-utils.js +3 -0
- package/dist/worktrees.js +929 -0
- package/dist/worktrees.test.js +189 -0
- package/dist/xml.js +92 -0
- package/dist/xml.test.js +32 -0
- package/package.json +98 -0
- package/schema.prisma +295 -0
- package/skills/batch/SKILL.md +87 -0
- package/skills/critique/SKILL.md +112 -0
- package/skills/egaki/SKILL.md +100 -0
- package/skills/errore/SKILL.md +647 -0
- package/skills/event-sourcing-state/SKILL.md +252 -0
- package/skills/gitchamber/SKILL.md +93 -0
- package/skills/goke/SKILL.md +644 -0
- package/skills/jitter/EDITOR.md +219 -0
- package/skills/jitter/EXPORT-INTERNALS.md +309 -0
- package/skills/jitter/SKILL.md +158 -0
- package/skills/jitter/jitter-clipboard.json +1042 -0
- package/skills/jitter/package.json +14 -0
- package/skills/jitter/tsconfig.json +15 -0
- package/skills/jitter/utils/actions.ts +212 -0
- package/skills/jitter/utils/export.ts +114 -0
- package/skills/jitter/utils/index.ts +141 -0
- package/skills/jitter/utils/snapshot.ts +154 -0
- package/skills/jitter/utils/traverse.ts +246 -0
- package/skills/jitter/utils/types.ts +279 -0
- package/skills/jitter/utils/wait.ts +133 -0
- package/skills/lintcn/SKILL.md +873 -0
- package/skills/new-skill/SKILL.md +211 -0
- package/skills/npm-package/SKILL.md +239 -0
- package/skills/playwriter/SKILL.md +35 -0
- package/skills/proxyman/SKILL.md +215 -0
- package/skills/security-review/SKILL.md +208 -0
- package/skills/simplify/SKILL.md +58 -0
- package/skills/spiceflow/SKILL.md +14 -0
- package/skills/termcast/SKILL.md +945 -0
- package/skills/tuistory/SKILL.md +250 -0
- package/skills/usecomputer/SKILL.md +264 -0
- package/skills/x-articles/SKILL.md +554 -0
- package/skills/zele/SKILL.md +112 -0
- package/skills/zustand-centralized-state/SKILL.md +1004 -0
- package/src/agent-model.e2e.test.ts +976 -0
- package/src/ai-tool-to-genai.test.ts +296 -0
- package/src/ai-tool-to-genai.ts +283 -0
- package/src/ai-tool.ts +39 -0
- package/src/anthropic-auth-plugin.test.ts +159 -0
- package/src/anthropic-auth-plugin.ts +861 -0
- package/src/anthropic-auth-state.ts +282 -0
- package/src/bin.ts +111 -0
- package/src/channel-management.ts +334 -0
- package/src/cli-parsing.test.ts +195 -0
- package/src/cli-send-thread.e2e.test.ts +464 -0
- package/src/cli.ts +4581 -0
- package/src/commands/abort.ts +89 -0
- package/src/commands/action-buttons.ts +364 -0
- package/src/commands/add-project.ts +149 -0
- package/src/commands/agent.ts +473 -0
- package/src/commands/ask-question.ts +390 -0
- package/src/commands/btw.ts +164 -0
- package/src/commands/compact.ts +157 -0
- package/src/commands/context-usage.ts +199 -0
- package/src/commands/create-new-project.ts +190 -0
- package/src/commands/diff.ts +91 -0
- package/src/commands/file-upload.ts +389 -0
- package/src/commands/fork.ts +321 -0
- package/src/commands/gemini-apikey.ts +104 -0
- package/src/commands/login.ts +1173 -0
- package/src/commands/mcp.ts +307 -0
- package/src/commands/memory-snapshot.ts +30 -0
- package/src/commands/mention-mode.ts +68 -0
- package/src/commands/merge-worktree.ts +223 -0
- package/src/commands/model-variant.ts +483 -0
- package/src/commands/model.ts +1053 -0
- package/src/commands/new-worktree.ts +510 -0
- package/src/commands/paginated-select.ts +81 -0
- package/src/commands/permissions.ts +397 -0
- package/src/commands/queue.ts +271 -0
- package/src/commands/remove-project.ts +155 -0
- package/src/commands/restart-opencode-server.ts +162 -0
- package/src/commands/resume.ts +230 -0
- package/src/commands/run-command.ts +123 -0
- package/src/commands/screenshare.test.ts +30 -0
- package/src/commands/screenshare.ts +366 -0
- package/src/commands/session-id.ts +109 -0
- package/src/commands/session.ts +227 -0
- package/src/commands/share.ts +106 -0
- package/src/commands/tasks.ts +293 -0
- package/src/commands/types.ts +25 -0
- package/src/commands/undo-redo.ts +386 -0
- package/src/commands/unset-model.ts +173 -0
- package/src/commands/upgrade.ts +52 -0
- package/src/commands/user-command.ts +198 -0
- package/src/commands/verbosity.ts +173 -0
- package/src/commands/worktree-settings.ts +70 -0
- package/src/commands/worktrees.ts +552 -0
- package/src/condense-memory.ts +36 -0
- package/src/config.ts +111 -0
- package/src/context-awareness-plugin.test.ts +142 -0
- package/src/context-awareness-plugin.ts +510 -0
- package/src/critique-utils.ts +139 -0
- package/src/database.ts +1876 -0
- package/src/db.test.ts +162 -0
- package/src/db.ts +286 -0
- package/src/debounce-timeout.ts +43 -0
- package/src/debounced-process-flush.ts +104 -0
- package/src/discord-bot.ts +1330 -0
- package/src/discord-command-registration.ts +693 -0
- package/src/discord-urls.ts +88 -0
- package/src/discord-utils.test.ts +153 -0
- package/src/discord-utils.ts +800 -0
- package/src/errors.ts +201 -0
- package/src/escape-backticks.test.ts +469 -0
- package/src/event-stream-real-capture.e2e.test.ts +692 -0
- package/src/eventsource-parser.test.ts +351 -0
- package/src/exec-async.ts +35 -0
- package/src/external-opencode-sync.ts +685 -0
- package/src/format-tables.test.ts +335 -0
- package/src/format-tables.ts +445 -0
- package/src/forum-sync/config.ts +92 -0
- package/src/forum-sync/discord-operations.ts +241 -0
- package/src/forum-sync/index.ts +9 -0
- package/src/forum-sync/markdown.ts +172 -0
- package/src/forum-sync/sync-to-discord.ts +595 -0
- package/src/forum-sync/sync-to-files.ts +294 -0
- package/src/forum-sync/types.ts +175 -0
- package/src/forum-sync/watchers.ts +454 -0
- package/src/gateway-proxy-reconnect.e2e.test.ts +523 -0
- package/src/gateway-proxy.e2e.test.ts +640 -0
- package/src/genai-worker-wrapper.ts +164 -0
- package/src/genai-worker.ts +386 -0
- package/src/genai.ts +321 -0
- package/src/generated/browser.ts +114 -0
- package/src/generated/client.ts +138 -0
- package/src/generated/commonInputTypes.ts +736 -0
- package/src/generated/enums.ts +88 -0
- package/src/generated/internal/class.ts +384 -0
- package/src/generated/internal/prismaNamespace.ts +2386 -0
- package/src/generated/internal/prismaNamespaceBrowser.ts +326 -0
- package/src/generated/models/bot_api_keys.ts +1288 -0
- package/src/generated/models/bot_tokens.ts +1656 -0
- package/src/generated/models/channel_agents.ts +1256 -0
- package/src/generated/models/channel_directories.ts +1859 -0
- package/src/generated/models/channel_mention_mode.ts +1300 -0
- package/src/generated/models/channel_models.ts +1288 -0
- package/src/generated/models/channel_verbosity.ts +1228 -0
- package/src/generated/models/channel_worktrees.ts +1300 -0
- package/src/generated/models/forum_sync_configs.ts +1452 -0
- package/src/generated/models/global_models.ts +1288 -0
- package/src/generated/models/ipc_requests.ts +1485 -0
- package/src/generated/models/part_messages.ts +1302 -0
- package/src/generated/models/scheduled_tasks.ts +2320 -0
- package/src/generated/models/session_agents.ts +1086 -0
- package/src/generated/models/session_events.ts +1439 -0
- package/src/generated/models/session_models.ts +1114 -0
- package/src/generated/models/session_start_sources.ts +1408 -0
- package/src/generated/models/thread_sessions.ts +1781 -0
- package/src/generated/models/thread_worktrees.ts +1356 -0
- package/src/generated/models.ts +30 -0
- package/src/heap-monitor.ts +152 -0
- package/src/hrana-server.test.ts +434 -0
- package/src/hrana-server.ts +314 -0
- package/src/html-actions.test.ts +87 -0
- package/src/html-actions.ts +174 -0
- package/src/html-components.test.ts +38 -0
- package/src/html-components.ts +181 -0
- package/src/image-optimizer-plugin.ts +194 -0
- package/src/image-utils.ts +149 -0
- package/src/interaction-handler.ts +576 -0
- package/src/ipc-polling.ts +326 -0
- package/src/ipc-tools-plugin.ts +236 -0
- package/src/kimaki-digital-twin.e2e.test.ts +199 -0
- package/src/kimaki-opencode-plugin-loading.e2e.test.ts +109 -0
- package/src/kimaki-opencode-plugin.test.ts +108 -0
- package/src/kimaki-opencode-plugin.ts +18 -0
- package/src/limit-heading-depth.test.ts +116 -0
- package/src/limit-heading-depth.ts +26 -0
- package/src/logger.ts +208 -0
- package/src/markdown.test.ts +308 -0
- package/src/markdown.ts +410 -0
- package/src/message-finish-field.e2e.test.ts +192 -0
- package/src/message-formatting.test.ts +81 -0
- package/src/message-formatting.ts +533 -0
- package/src/message-preprocessing.ts +455 -0
- package/src/onboarding-tutorial.ts +176 -0
- package/src/onboarding-welcome.ts +49 -0
- package/src/openai-realtime.ts +358 -0
- package/src/opencode-command-detection.test.ts +307 -0
- package/src/opencode-command-detection.ts +76 -0
- package/src/opencode-command.test.ts +70 -0
- package/src/opencode-command.ts +188 -0
- package/src/opencode-interrupt-plugin.test.ts +677 -0
- package/src/opencode-interrupt-plugin.ts +477 -0
- package/src/opencode.ts +1110 -0
- package/src/otto/branding.ts +23 -0
- package/src/otto/index.ts +22 -0
- package/src/parse-permission-rules.test.ts +127 -0
- package/src/patch-text-parser.ts +107 -0
- package/src/plugin-logger.ts +68 -0
- package/src/privacy-sanitizer.ts +142 -0
- package/src/queue-advanced-abort.e2e.test.ts +382 -0
- package/src/queue-advanced-action-buttons.e2e.test.ts +268 -0
- package/src/queue-advanced-e2e-setup.ts +873 -0
- package/src/queue-advanced-footer.e2e.test.ts +576 -0
- package/src/queue-advanced-model-switch.e2e.test.ts +383 -0
- package/src/queue-advanced-permissions-typing.e2e.test.ts +245 -0
- package/src/queue-advanced-question.e2e.test.ts +316 -0
- package/src/queue-advanced-typing-interrupt.e2e.test.ts +146 -0
- package/src/queue-advanced-typing.e2e.test.ts +199 -0
- package/src/queue-drain-after-interactive-ui.e2e.test.ts +151 -0
- package/src/queue-interrupt-drain.e2e.test.ts +166 -0
- package/src/queue-question-select-drain.e2e.test.ts +152 -0
- package/src/runtime-idle-sweeper.ts +76 -0
- package/src/runtime-lifecycle.e2e.test.ts +641 -0
- package/src/schema.sql +173 -0
- package/src/sentry.ts +26 -0
- package/src/session-handler/agent-utils.ts +97 -0
- package/src/session-handler/event-stream-fixtures/real-session-action-buttons.jsonl +45 -0
- package/src/session-handler/event-stream-fixtures/real-session-footer-suppressed-on-pre-idle-interrupt.jsonl +40 -0
- package/src/session-handler/event-stream-fixtures/real-session-permission-external-file.jsonl +23 -0
- package/src/session-handler/event-stream-fixtures/real-session-task-normal.jsonl +22 -0
- package/src/session-handler/event-stream-fixtures/real-session-task-three-parallel-sleeps.jsonl +277 -0
- package/src/session-handler/event-stream-fixtures/real-session-task-user-interruption.jsonl +46 -0
- package/src/session-handler/event-stream-fixtures/session-abort-after-idle-race.jsonl +21 -0
- package/src/session-handler/event-stream-fixtures/session-concurrent-messages-serialized.jsonl +56 -0
- package/src/session-handler/event-stream-fixtures/session-explicit-abort.jsonl +44 -0
- package/src/session-handler/event-stream-fixtures/session-normal-completion.jsonl +29 -0
- package/src/session-handler/event-stream-fixtures/session-tool-call-noisy-stream.jsonl +29 -0
- package/src/session-handler/event-stream-fixtures/session-two-completions-same-session.jsonl +50 -0
- package/src/session-handler/event-stream-fixtures/session-user-interruption.jsonl +59 -0
- package/src/session-handler/event-stream-fixtures/session-voice-queued-followup.jsonl +52 -0
- package/src/session-handler/event-stream-state.test.ts +645 -0
- package/src/session-handler/event-stream-state.ts +608 -0
- package/src/session-handler/model-utils.ts +183 -0
- package/src/session-handler/opencode-session-event-log.ts +130 -0
- package/src/session-handler/thread-runtime-state.ts +212 -0
- package/src/session-handler/thread-session-runtime.ts +4281 -0
- package/src/session-handler.ts +15 -0
- package/src/session-search.test.ts +50 -0
- package/src/session-search.ts +148 -0
- package/src/session-title-rename.test.ts +112 -0
- package/src/startup-service.ts +200 -0
- package/src/startup-time.e2e.test.ts +373 -0
- package/src/store.ts +122 -0
- package/src/system-message.test.ts +612 -0
- package/src/system-message.ts +723 -0
- package/src/task-runner.ts +421 -0
- package/src/task-schedule.test.ts +84 -0
- package/src/task-schedule.ts +311 -0
- package/src/test-utils.ts +435 -0
- package/src/thinking-utils.ts +61 -0
- package/src/thread-message-queue.e2e.test.ts +1219 -0
- package/src/tools.ts +430 -0
- package/src/undici.d.ts +12 -0
- package/src/undo-redo.e2e.test.ts +209 -0
- package/src/unnest-code-blocks.test.ts +713 -0
- package/src/unnest-code-blocks.ts +185 -0
- package/src/upgrade.ts +127 -0
- package/src/utils.ts +212 -0
- package/src/voice-attachment.ts +51 -0
- package/src/voice-handler.ts +908 -0
- package/src/voice-message.e2e.test.ts +1255 -0
- package/src/voice.test.ts +281 -0
- package/src/voice.ts +627 -0
- package/src/wait-session.ts +147 -0
- package/src/websockify.ts +101 -0
- package/src/worker-types.ts +64 -0
- package/src/worktree-lifecycle.e2e.test.ts +391 -0
- package/src/worktree-utils.ts +4 -0
- package/src/worktrees.test.ts +223 -0
- package/src/worktrees.ts +1294 -0
- package/src/xml.test.ts +38 -0
- package/src/xml.ts +121 -0
|
@@ -0,0 +1,1219 @@
|
|
|
1
|
+
// E2e tests for basic per-thread message queue ordering.
|
|
2
|
+
// Advanced interrupt/abort/retry tests are in thread-queue-advanced.e2e.test.ts.
|
|
3
|
+
//
|
|
4
|
+
// Uses opencode-deterministic-provider which returns canned responses instantly
|
|
5
|
+
// (no real LLM calls), so poll timeouts can be aggressive (4s). The only real
|
|
6
|
+
// latency is OpenCode server startup (beforeAll) and intentional partDelaysMs
|
|
7
|
+
// in matchers (100ms for user-reply).
|
|
8
|
+
//
|
|
9
|
+
// If total duration of a file exceeds ~10s, split into a new test file
|
|
10
|
+
// so vitest can parallelize across files.
|
|
11
|
+
|
|
12
|
+
import fs from 'node:fs'
|
|
13
|
+
import path from 'node:path'
|
|
14
|
+
import url from 'node:url'
|
|
15
|
+
import { describe, beforeAll, afterAll, test, expect } from 'vitest'
|
|
16
|
+
import { ChannelType, Client, GatewayIntentBits, Partials } from 'discord.js'
|
|
17
|
+
import { DigitalDiscord } from 'discord-digital-twin/src'
|
|
18
|
+
import {
|
|
19
|
+
buildDeterministicOpencodeConfig,
|
|
20
|
+
type DeterministicMatcher,
|
|
21
|
+
} from 'opencode-deterministic-provider'
|
|
22
|
+
import {
|
|
23
|
+
setDataDir,
|
|
24
|
+
} from './config.js'
|
|
25
|
+
import { store } from './store.js'
|
|
26
|
+
import { startDiscordBot } from './discord-bot.js'
|
|
27
|
+
import {
|
|
28
|
+
setBotToken,
|
|
29
|
+
initDatabase,
|
|
30
|
+
closeDatabase,
|
|
31
|
+
setChannelDirectory,
|
|
32
|
+
setChannelVerbosity,
|
|
33
|
+
getChannelVerbosity,
|
|
34
|
+
type VerbosityLevel,
|
|
35
|
+
} from './database.js'
|
|
36
|
+
import { startHranaServer, stopHranaServer } from './hrana-server.js'
|
|
37
|
+
import { initializeOpencodeForDirectory, stopOpencodeServer } from './opencode.js'
|
|
38
|
+
import {
|
|
39
|
+
chooseLockPort,
|
|
40
|
+
cleanupTestSessions,
|
|
41
|
+
initTestGitRepo,
|
|
42
|
+
waitForFooterMessage,
|
|
43
|
+
waitForBotMessageContaining,
|
|
44
|
+
waitForMessageById,
|
|
45
|
+
waitForBotMessageCount,
|
|
46
|
+
waitForBotReplyAfterUserMessage,
|
|
47
|
+
waitForThreadState,
|
|
48
|
+
} from './test-utils.js'
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
const e2eTest = describe
|
|
52
|
+
|
|
53
|
+
function createRunDirectories() {
|
|
54
|
+
const root = path.resolve(process.cwd(), 'tmp', 'thread-queue-e2e')
|
|
55
|
+
fs.mkdirSync(root, { recursive: true })
|
|
56
|
+
|
|
57
|
+
const dataDir = fs.mkdtempSync(path.join(root, 'data-'))
|
|
58
|
+
const projectDirectory = path.join(root, 'project')
|
|
59
|
+
fs.mkdirSync(projectDirectory, { recursive: true })
|
|
60
|
+
initTestGitRepo(projectDirectory)
|
|
61
|
+
|
|
62
|
+
return { root, dataDir, projectDirectory }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function createDiscordJsClient({ restUrl }: { restUrl: string }) {
|
|
66
|
+
return new Client({
|
|
67
|
+
intents: [
|
|
68
|
+
GatewayIntentBits.Guilds,
|
|
69
|
+
GatewayIntentBits.GuildMessages,
|
|
70
|
+
GatewayIntentBits.MessageContent,
|
|
71
|
+
GatewayIntentBits.GuildVoiceStates,
|
|
72
|
+
],
|
|
73
|
+
partials: [
|
|
74
|
+
Partials.Channel,
|
|
75
|
+
Partials.Message,
|
|
76
|
+
Partials.User,
|
|
77
|
+
Partials.ThreadMember,
|
|
78
|
+
],
|
|
79
|
+
rest: {
|
|
80
|
+
api: restUrl,
|
|
81
|
+
version: '10',
|
|
82
|
+
},
|
|
83
|
+
})
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function createDeterministicMatchers() {
|
|
87
|
+
const bashCreateFileMatcher: DeterministicMatcher = {
|
|
88
|
+
id: 'bash-create-file',
|
|
89
|
+
priority: 130,
|
|
90
|
+
when: {
|
|
91
|
+
lastMessageRole: 'user',
|
|
92
|
+
rawPromptIncludes: 'BASH_TOOL_FILE_MARKER',
|
|
93
|
+
},
|
|
94
|
+
then: {
|
|
95
|
+
parts: [
|
|
96
|
+
{ type: 'stream-start', warnings: [] },
|
|
97
|
+
{ type: 'text-start', id: 'bash-create-file' },
|
|
98
|
+
{
|
|
99
|
+
type: 'text-delta',
|
|
100
|
+
id: 'bash-create-file',
|
|
101
|
+
delta: 'running create file',
|
|
102
|
+
},
|
|
103
|
+
{ type: 'text-end', id: 'bash-create-file' },
|
|
104
|
+
{
|
|
105
|
+
type: 'tool-call',
|
|
106
|
+
toolCallId: 'bash-create-file-call',
|
|
107
|
+
toolName: 'bash',
|
|
108
|
+
input: JSON.stringify({
|
|
109
|
+
command: 'mkdir -p tmp && printf "created" > tmp/bash-tool-executed.txt',
|
|
110
|
+
description: 'Create marker file for e2e test',
|
|
111
|
+
hasSideEffect: true,
|
|
112
|
+
}),
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
type: 'finish',
|
|
116
|
+
finishReason: 'tool-calls',
|
|
117
|
+
usage: {
|
|
118
|
+
inputTokens: 1,
|
|
119
|
+
outputTokens: 1,
|
|
120
|
+
totalTokens: 2,
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
],
|
|
124
|
+
},
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const bashCreateFileFollowupMatcher: DeterministicMatcher = {
|
|
128
|
+
id: 'bash-create-file-followup',
|
|
129
|
+
priority: 120,
|
|
130
|
+
when: {
|
|
131
|
+
lastMessageRole: 'tool',
|
|
132
|
+
rawPromptIncludes: 'BASH_TOOL_FILE_MARKER',
|
|
133
|
+
},
|
|
134
|
+
then: {
|
|
135
|
+
parts: [
|
|
136
|
+
{ type: 'stream-start', warnings: [] },
|
|
137
|
+
{ type: 'text-start', id: 'bash-create-file-followup' },
|
|
138
|
+
{
|
|
139
|
+
type: 'text-delta',
|
|
140
|
+
id: 'bash-create-file-followup',
|
|
141
|
+
delta: 'file created',
|
|
142
|
+
},
|
|
143
|
+
{ type: 'text-end', id: 'bash-create-file-followup' },
|
|
144
|
+
{
|
|
145
|
+
type: 'finish',
|
|
146
|
+
finishReason: 'stop',
|
|
147
|
+
usage: {
|
|
148
|
+
inputTokens: 1,
|
|
149
|
+
outputTokens: 1,
|
|
150
|
+
totalTokens: 2,
|
|
151
|
+
},
|
|
152
|
+
},
|
|
153
|
+
],
|
|
154
|
+
},
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const raceFinalReplyMatcher: DeterministicMatcher = {
|
|
158
|
+
id: 'race-final-reply',
|
|
159
|
+
priority: 110,
|
|
160
|
+
when: {
|
|
161
|
+
latestUserTextIncludes: 'Reply with exactly: race-final',
|
|
162
|
+
},
|
|
163
|
+
then: {
|
|
164
|
+
parts: [
|
|
165
|
+
{ type: 'stream-start', warnings: [] },
|
|
166
|
+
{ type: 'text-start', id: 'race-final' },
|
|
167
|
+
{ type: 'text-delta', id: 'race-final', delta: 'race-final' },
|
|
168
|
+
{ type: 'text-end', id: 'race-final' },
|
|
169
|
+
{
|
|
170
|
+
type: 'finish',
|
|
171
|
+
finishReason: 'stop',
|
|
172
|
+
usage: {
|
|
173
|
+
inputTokens: 1,
|
|
174
|
+
outputTokens: 1,
|
|
175
|
+
totalTokens: 2,
|
|
176
|
+
},
|
|
177
|
+
},
|
|
178
|
+
],
|
|
179
|
+
// Delay first output to widen the stale-idle window. The race happens
|
|
180
|
+
// in <1ms; 500ms is plenty to keep the window reliably open.
|
|
181
|
+
partDelaysMs: [0, 500, 0, 0, 0],
|
|
182
|
+
},
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Slow matcher for "hotel" so the 200ms sleep in the queueing test
|
|
186
|
+
// guarantees "india" arrives while hotel is still streaming.
|
|
187
|
+
const hotelSlowMatcher: DeterministicMatcher = {
|
|
188
|
+
id: 'hotel-slow-reply',
|
|
189
|
+
priority: 20,
|
|
190
|
+
when: {
|
|
191
|
+
latestUserTextIncludes: 'Reply with exactly: hotel',
|
|
192
|
+
},
|
|
193
|
+
then: {
|
|
194
|
+
parts: [
|
|
195
|
+
{ type: 'stream-start', warnings: [] },
|
|
196
|
+
{ type: 'text-start', id: 'hotel-reply' },
|
|
197
|
+
{ type: 'text-delta', id: 'hotel-reply', delta: 'ok' },
|
|
198
|
+
{ type: 'text-end', id: 'hotel-reply' },
|
|
199
|
+
{
|
|
200
|
+
type: 'finish',
|
|
201
|
+
finishReason: 'stop',
|
|
202
|
+
usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 },
|
|
203
|
+
},
|
|
204
|
+
],
|
|
205
|
+
partDelaysMs: [0, 100, 300, 0, 0],
|
|
206
|
+
},
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const userReplyMatcher: DeterministicMatcher = {
|
|
210
|
+
id: 'user-reply',
|
|
211
|
+
priority: 10,
|
|
212
|
+
when: {
|
|
213
|
+
lastMessageRole: 'user',
|
|
214
|
+
rawPromptIncludes: 'Reply with exactly:',
|
|
215
|
+
},
|
|
216
|
+
then: {
|
|
217
|
+
parts: [
|
|
218
|
+
{ type: 'stream-start', warnings: [] },
|
|
219
|
+
{ type: 'text-start', id: 'default-reply' },
|
|
220
|
+
{ type: 'text-delta', id: 'default-reply', delta: 'ok' },
|
|
221
|
+
{ type: 'text-end', id: 'default-reply' },
|
|
222
|
+
{
|
|
223
|
+
type: 'finish',
|
|
224
|
+
finishReason: 'stop',
|
|
225
|
+
usage: {
|
|
226
|
+
inputTokens: 1,
|
|
227
|
+
outputTokens: 1,
|
|
228
|
+
totalTokens: 2,
|
|
229
|
+
},
|
|
230
|
+
},
|
|
231
|
+
],
|
|
232
|
+
partDelaysMs: [0, 100, 0, 0, 0],
|
|
233
|
+
},
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return [
|
|
237
|
+
bashCreateFileMatcher,
|
|
238
|
+
bashCreateFileFollowupMatcher,
|
|
239
|
+
raceFinalReplyMatcher,
|
|
240
|
+
hotelSlowMatcher,
|
|
241
|
+
userReplyMatcher,
|
|
242
|
+
]
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const TEST_USER_ID = '200000000000000777'
|
|
246
|
+
const TEXT_CHANNEL_ID = '200000000000000778'
|
|
247
|
+
|
|
248
|
+
e2eTest('thread message queue ordering', () => {
|
|
249
|
+
let directories: ReturnType<typeof createRunDirectories>
|
|
250
|
+
let discord: DigitalDiscord
|
|
251
|
+
let botClient: Client
|
|
252
|
+
let previousDefaultVerbosity: VerbosityLevel | null =
|
|
253
|
+
null
|
|
254
|
+
let testStartTime = Date.now()
|
|
255
|
+
|
|
256
|
+
beforeAll(async () => {
|
|
257
|
+
testStartTime = Date.now()
|
|
258
|
+
directories = createRunDirectories()
|
|
259
|
+
const lockPort = chooseLockPort({ key: TEXT_CHANNEL_ID })
|
|
260
|
+
|
|
261
|
+
process.env['KIMAKI_LOCK_PORT'] = String(lockPort)
|
|
262
|
+
setDataDir(directories.dataDir)
|
|
263
|
+
previousDefaultVerbosity = store.getState().defaultVerbosity
|
|
264
|
+
store.setState({ defaultVerbosity: 'tools_and_text' })
|
|
265
|
+
|
|
266
|
+
const digitalDiscordDbPath = path.join(
|
|
267
|
+
directories.dataDir,
|
|
268
|
+
'digital-discord.db',
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
discord = new DigitalDiscord({
|
|
272
|
+
guild: {
|
|
273
|
+
name: 'Queue E2E Guild',
|
|
274
|
+
ownerId: TEST_USER_ID,
|
|
275
|
+
},
|
|
276
|
+
channels: [
|
|
277
|
+
{
|
|
278
|
+
id: TEXT_CHANNEL_ID,
|
|
279
|
+
name: 'queue-e2e',
|
|
280
|
+
type: ChannelType.GuildText,
|
|
281
|
+
},
|
|
282
|
+
],
|
|
283
|
+
users: [
|
|
284
|
+
{
|
|
285
|
+
id: TEST_USER_ID,
|
|
286
|
+
username: 'queue-tester',
|
|
287
|
+
},
|
|
288
|
+
],
|
|
289
|
+
dbUrl: `file:${digitalDiscordDbPath}`,
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
await discord.start()
|
|
293
|
+
|
|
294
|
+
const providerNpm = url
|
|
295
|
+
.pathToFileURL(
|
|
296
|
+
path.resolve(
|
|
297
|
+
process.cwd(),
|
|
298
|
+
'..',
|
|
299
|
+
'opencode-deterministic-provider',
|
|
300
|
+
'src',
|
|
301
|
+
'index.ts',
|
|
302
|
+
),
|
|
303
|
+
)
|
|
304
|
+
.toString()
|
|
305
|
+
|
|
306
|
+
const opencodeConfig = buildDeterministicOpencodeConfig({
|
|
307
|
+
providerName: 'deterministic-provider',
|
|
308
|
+
providerNpm,
|
|
309
|
+
model: 'deterministic-v2',
|
|
310
|
+
smallModel: 'deterministic-v2',
|
|
311
|
+
settings: {
|
|
312
|
+
strict: false,
|
|
313
|
+
matchers: createDeterministicMatchers(),
|
|
314
|
+
},
|
|
315
|
+
})
|
|
316
|
+
fs.writeFileSync(
|
|
317
|
+
path.join(directories.projectDirectory, 'opencode.json'),
|
|
318
|
+
JSON.stringify(opencodeConfig, null, 2),
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
const dbPath = path.join(directories.dataDir, 'discord-sessions.db')
|
|
322
|
+
const hranaResult = await startHranaServer({ dbPath })
|
|
323
|
+
if (hranaResult instanceof Error) {
|
|
324
|
+
throw hranaResult
|
|
325
|
+
}
|
|
326
|
+
process.env['KIMAKI_DB_URL'] = hranaResult
|
|
327
|
+
await initDatabase()
|
|
328
|
+
await setBotToken(discord.botUserId, discord.botToken)
|
|
329
|
+
|
|
330
|
+
await setChannelDirectory({
|
|
331
|
+
channelId: TEXT_CHANNEL_ID,
|
|
332
|
+
directory: directories.projectDirectory,
|
|
333
|
+
channelType: 'text',
|
|
334
|
+
})
|
|
335
|
+
await setChannelVerbosity(TEXT_CHANNEL_ID, 'tools_and_text')
|
|
336
|
+
const channelVerbosity = await getChannelVerbosity(TEXT_CHANNEL_ID)
|
|
337
|
+
expect(channelVerbosity).toBe('tools_and_text')
|
|
338
|
+
|
|
339
|
+
botClient = createDiscordJsClient({ restUrl: discord.restUrl })
|
|
340
|
+
await startDiscordBot({
|
|
341
|
+
token: discord.botToken,
|
|
342
|
+
appId: discord.botUserId,
|
|
343
|
+
discordClient: botClient,
|
|
344
|
+
})
|
|
345
|
+
|
|
346
|
+
// Pre-warm the opencode server so the first test doesn't include
|
|
347
|
+
// server startup time (~3-4s) inside its 4s poll timeouts.
|
|
348
|
+
const warmup = await initializeOpencodeForDirectory(
|
|
349
|
+
directories.projectDirectory,
|
|
350
|
+
)
|
|
351
|
+
if (warmup instanceof Error) {
|
|
352
|
+
throw warmup
|
|
353
|
+
}
|
|
354
|
+
}, 60_000)
|
|
355
|
+
|
|
356
|
+
afterAll(async () => {
|
|
357
|
+
if (directories) {
|
|
358
|
+
await cleanupTestSessions({
|
|
359
|
+
projectDirectory: directories.projectDirectory,
|
|
360
|
+
testStartTime,
|
|
361
|
+
})
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
if (botClient) {
|
|
365
|
+
botClient.destroy()
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
await stopOpencodeServer()
|
|
369
|
+
await Promise.all([
|
|
370
|
+
closeDatabase().catch(() => {
|
|
371
|
+
return
|
|
372
|
+
}),
|
|
373
|
+
stopHranaServer().catch(() => {
|
|
374
|
+
return
|
|
375
|
+
}),
|
|
376
|
+
discord?.stop().catch(() => {
|
|
377
|
+
return
|
|
378
|
+
}),
|
|
379
|
+
])
|
|
380
|
+
|
|
381
|
+
delete process.env['KIMAKI_LOCK_PORT']
|
|
382
|
+
delete process.env['KIMAKI_DB_URL']
|
|
383
|
+
if (previousDefaultVerbosity) {
|
|
384
|
+
store.setState({ defaultVerbosity: previousDefaultVerbosity })
|
|
385
|
+
}
|
|
386
|
+
if (directories) {
|
|
387
|
+
fs.rmSync(directories.dataDir, { recursive: true, force: true })
|
|
388
|
+
}
|
|
389
|
+
}, 10_000)
|
|
390
|
+
|
|
391
|
+
test(
|
|
392
|
+
'first prompt after cold opencode server start still streams text parts',
|
|
393
|
+
async () => {
|
|
394
|
+
// Reproduce cold-start path: clear in-memory server/client registry so
|
|
395
|
+
// runtime startEventListener() runs once before initialize and exits with
|
|
396
|
+
// "No OpenCode client". The first prompt must still show text parts.
|
|
397
|
+
await stopOpencodeServer()
|
|
398
|
+
|
|
399
|
+
const prompt = 'Reply with exactly: cold-start-stream'
|
|
400
|
+
|
|
401
|
+
await discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
|
|
402
|
+
content: prompt,
|
|
403
|
+
})
|
|
404
|
+
|
|
405
|
+
const thread = await discord.channel(TEXT_CHANNEL_ID).waitForThread({
|
|
406
|
+
timeout: 4_000,
|
|
407
|
+
predicate: (t) => {
|
|
408
|
+
return t.name === prompt
|
|
409
|
+
},
|
|
410
|
+
})
|
|
411
|
+
|
|
412
|
+
await waitForBotMessageContaining({
|
|
413
|
+
discord,
|
|
414
|
+
threadId: thread.id,
|
|
415
|
+
userId: TEST_USER_ID,
|
|
416
|
+
text: '⬥ ok',
|
|
417
|
+
timeout: 10_000,
|
|
418
|
+
})
|
|
419
|
+
|
|
420
|
+
await waitForFooterMessage({
|
|
421
|
+
discord,
|
|
422
|
+
threadId: thread.id,
|
|
423
|
+
timeout: 4_000,
|
|
424
|
+
})
|
|
425
|
+
|
|
426
|
+
expect(await discord.thread(thread.id).text()).toMatchInlineSnapshot(`
|
|
427
|
+
"--- from: user (queue-tester)
|
|
428
|
+
Reply with exactly: cold-start-stream
|
|
429
|
+
--- from: assistant (TestBot)
|
|
430
|
+
⬥ ok
|
|
431
|
+
*project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
|
|
432
|
+
`)
|
|
433
|
+
},
|
|
434
|
+
12_000,
|
|
435
|
+
)
|
|
436
|
+
|
|
437
|
+
test(
|
|
438
|
+
'text message during active session gets processed',
|
|
439
|
+
async () => {
|
|
440
|
+
// 1. Send initial message to text channel → thread created + session established
|
|
441
|
+
await discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
|
|
442
|
+
content: 'Reply with exactly: alpha',
|
|
443
|
+
})
|
|
444
|
+
|
|
445
|
+
const thread = await discord.channel(TEXT_CHANNEL_ID).waitForThread({
|
|
446
|
+
timeout: 4_000,
|
|
447
|
+
predicate: (t) => {
|
|
448
|
+
return t.name === 'Reply with exactly: alpha'
|
|
449
|
+
},
|
|
450
|
+
})
|
|
451
|
+
|
|
452
|
+
const th = discord.thread(thread.id)
|
|
453
|
+
|
|
454
|
+
// Wait for the first bot reply so session is fully established in DB
|
|
455
|
+
const firstReply = await th.waitForBotReply({
|
|
456
|
+
timeout: 4_000,
|
|
457
|
+
})
|
|
458
|
+
expect(firstReply.content.trim().length).toBeGreaterThan(0)
|
|
459
|
+
|
|
460
|
+
// Snapshot bot message count before sending follow-up
|
|
461
|
+
const before = await th.getMessages()
|
|
462
|
+
const beforeBotCount = before.filter((m) => {
|
|
463
|
+
return m.author.id === discord.botUserId
|
|
464
|
+
}).length
|
|
465
|
+
|
|
466
|
+
// 2. Send follow-up message B into the thread — serialized by runtime's enqueueIncoming
|
|
467
|
+
await th.user(TEST_USER_ID).sendMessage({
|
|
468
|
+
content: 'Reply with exactly: beta',
|
|
469
|
+
})
|
|
470
|
+
|
|
471
|
+
// 3. Wait for exactly 1 new bot message (the response to B)
|
|
472
|
+
const after = await waitForBotMessageCount({
|
|
473
|
+
discord,
|
|
474
|
+
threadId: thread.id,
|
|
475
|
+
count: beforeBotCount + 1,
|
|
476
|
+
timeout: 4_000,
|
|
477
|
+
})
|
|
478
|
+
|
|
479
|
+
// 4. Verify at least 1 new bot message appeared for the follow-up.
|
|
480
|
+
// The bot may send additional messages per session (error reactions,
|
|
481
|
+
// session notifications) so we check >= not exact equality.
|
|
482
|
+
const afterBotMessages = after.filter((m) => {
|
|
483
|
+
return m.author.id === discord.botUserId
|
|
484
|
+
})
|
|
485
|
+
expect(afterBotMessages.length).toBeGreaterThanOrEqual(beforeBotCount + 1)
|
|
486
|
+
|
|
487
|
+
await waitForFooterMessage({
|
|
488
|
+
discord,
|
|
489
|
+
threadId: thread.id,
|
|
490
|
+
timeout: 8_000,
|
|
491
|
+
afterMessageIncludes: 'beta',
|
|
492
|
+
afterAuthorId: TEST_USER_ID,
|
|
493
|
+
})
|
|
494
|
+
|
|
495
|
+
const timeline = await th.text()
|
|
496
|
+
expect(timeline).toContain('Reply with exactly: alpha')
|
|
497
|
+
expect(timeline).toContain('Reply with exactly: beta')
|
|
498
|
+
expect(timeline).toContain('⬥ ok')
|
|
499
|
+
expect(timeline).toContain('*project ⋅ main ⋅')
|
|
500
|
+
// User B's message must appear before the new bot response
|
|
501
|
+
const userBIndex = after.findIndex((m) => {
|
|
502
|
+
return (
|
|
503
|
+
m.author.id === TEST_USER_ID &&
|
|
504
|
+
m.content.includes('beta')
|
|
505
|
+
)
|
|
506
|
+
})
|
|
507
|
+
const lastBotIndex = after.findLastIndex((m) => {
|
|
508
|
+
return m.author.id === discord.botUserId
|
|
509
|
+
})
|
|
510
|
+
|
|
511
|
+
expect(userBIndex).toBeGreaterThan(-1)
|
|
512
|
+
expect(lastBotIndex).toBeGreaterThan(-1)
|
|
513
|
+
expect(userBIndex).toBeLessThan(lastBotIndex)
|
|
514
|
+
|
|
515
|
+
// New bot response has non-empty content
|
|
516
|
+
const newBotReply = afterBotMessages[afterBotMessages.length - 1]!
|
|
517
|
+
expect(newBotReply.content.trim().length).toBeGreaterThan(0)
|
|
518
|
+
},
|
|
519
|
+
12_000,
|
|
520
|
+
)
|
|
521
|
+
|
|
522
|
+
test(
|
|
523
|
+
'two rapid text messages in thread — both processed in order',
|
|
524
|
+
async () => {
|
|
525
|
+
// 1. Send initial message to text channel → thread + session established
|
|
526
|
+
await discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
|
|
527
|
+
content: 'Reply with exactly: one',
|
|
528
|
+
})
|
|
529
|
+
|
|
530
|
+
const thread = await discord.channel(TEXT_CHANNEL_ID).waitForThread({
|
|
531
|
+
timeout: 4_000,
|
|
532
|
+
predicate: (t) => {
|
|
533
|
+
return t.name === 'Reply with exactly: one'
|
|
534
|
+
},
|
|
535
|
+
})
|
|
536
|
+
|
|
537
|
+
const th = discord.thread(thread.id)
|
|
538
|
+
|
|
539
|
+
// Wait for the first bot reply AND its footer so the first response
|
|
540
|
+
// cycle is fully complete before sending follow-ups. Without this,
|
|
541
|
+
// the footer for "one" can still be in-flight when the snapshot runs.
|
|
542
|
+
const firstReply = await th.waitForBotReply({
|
|
543
|
+
timeout: 4_000,
|
|
544
|
+
})
|
|
545
|
+
expect(firstReply.content.trim().length).toBeGreaterThan(0)
|
|
546
|
+
|
|
547
|
+
await waitForFooterMessage({
|
|
548
|
+
discord,
|
|
549
|
+
threadId: thread.id,
|
|
550
|
+
timeout: 4_000,
|
|
551
|
+
afterMessageIncludes: 'one',
|
|
552
|
+
afterAuthorId: TEST_USER_ID,
|
|
553
|
+
})
|
|
554
|
+
|
|
555
|
+
// Snapshot bot message count before sending follow-ups
|
|
556
|
+
const before = await th.getMessages()
|
|
557
|
+
const beforeBotCount = before.filter((m) => {
|
|
558
|
+
return m.author.id === discord.botUserId
|
|
559
|
+
}).length
|
|
560
|
+
|
|
561
|
+
// 2. Rapidly send messages B and C. With opencode queue mode,
|
|
562
|
+
// both messages are serialized by opencode's per-session loop.
|
|
563
|
+
await th.user(TEST_USER_ID).sendMessage({
|
|
564
|
+
content: 'Reply with exactly: two',
|
|
565
|
+
})
|
|
566
|
+
await th.user(TEST_USER_ID).sendMessage({
|
|
567
|
+
content: 'Reply with exactly: three',
|
|
568
|
+
})
|
|
569
|
+
|
|
570
|
+
// 3. Wait for a bot reply after message C.
|
|
571
|
+
const after = await waitForBotReplyAfterUserMessage({
|
|
572
|
+
discord,
|
|
573
|
+
threadId: thread.id,
|
|
574
|
+
userId: TEST_USER_ID,
|
|
575
|
+
userMessageIncludes: 'three',
|
|
576
|
+
timeout: 4_000,
|
|
577
|
+
})
|
|
578
|
+
|
|
579
|
+
// 4. Verify the latest user message got a bot reply.
|
|
580
|
+
const afterBotMessages = after.filter((m) => {
|
|
581
|
+
return m.author.id === discord.botUserId
|
|
582
|
+
})
|
|
583
|
+
expect(afterBotMessages.length).toBeGreaterThanOrEqual(beforeBotCount + 1)
|
|
584
|
+
|
|
585
|
+
await waitForFooterMessage({
|
|
586
|
+
discord,
|
|
587
|
+
threadId: thread.id,
|
|
588
|
+
timeout: 4_000,
|
|
589
|
+
afterMessageIncludes: 'three',
|
|
590
|
+
afterAuthorId: TEST_USER_ID,
|
|
591
|
+
})
|
|
592
|
+
|
|
593
|
+
expect(await th.text()).toMatchInlineSnapshot(`
|
|
594
|
+
"--- from: user (queue-tester)
|
|
595
|
+
Reply with exactly: one
|
|
596
|
+
--- from: assistant (TestBot)
|
|
597
|
+
⬥ ok
|
|
598
|
+
*project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
|
|
599
|
+
--- from: user (queue-tester)
|
|
600
|
+
Reply with exactly: two
|
|
601
|
+
Reply with exactly: three
|
|
602
|
+
--- from: assistant (TestBot)
|
|
603
|
+
⬥ ok
|
|
604
|
+
⬥ ok
|
|
605
|
+
*project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
|
|
606
|
+
`)
|
|
607
|
+
const userThreeIndex = after.findIndex((message) => {
|
|
608
|
+
return (
|
|
609
|
+
message.author.id === TEST_USER_ID &&
|
|
610
|
+
message.content.includes('three')
|
|
611
|
+
)
|
|
612
|
+
})
|
|
613
|
+
expect(userThreeIndex).toBeGreaterThan(-1)
|
|
614
|
+
|
|
615
|
+
const botAfterThreeIndex = after.findIndex((message, index) => {
|
|
616
|
+
return index > userThreeIndex && message.author.id === discord.botUserId
|
|
617
|
+
})
|
|
618
|
+
expect(botAfterThreeIndex).toBeGreaterThan(userThreeIndex)
|
|
619
|
+
|
|
620
|
+
const newBotReplies = afterBotMessages.slice(beforeBotCount)
|
|
621
|
+
expect(newBotReplies.some((reply) => {
|
|
622
|
+
return reply.content.trim().length > 0
|
|
623
|
+
})).toBe(true)
|
|
624
|
+
|
|
625
|
+
const finalState = await waitForThreadState({
|
|
626
|
+
threadId: thread.id,
|
|
627
|
+
predicate: (state) => {
|
|
628
|
+
return state.queueItems.length === 0
|
|
629
|
+
},
|
|
630
|
+
timeout: 4_000,
|
|
631
|
+
description: 'queue empty after rapid interrupts',
|
|
632
|
+
})
|
|
633
|
+
expect(finalState.queueItems.length).toBe(0)
|
|
634
|
+
},
|
|
635
|
+
8_000,
|
|
636
|
+
)
|
|
637
|
+
|
|
638
|
+
test(
|
|
639
|
+
'normal messages bypass local queue and still show assistant text parts',
|
|
640
|
+
async () => {
|
|
641
|
+
const setupPrompt = 'Reply with exactly: opencode-queue-setup'
|
|
642
|
+
await discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
|
|
643
|
+
content: setupPrompt,
|
|
644
|
+
})
|
|
645
|
+
|
|
646
|
+
const thread = await discord.channel(TEXT_CHANNEL_ID).waitForThread({
|
|
647
|
+
timeout: 4_000,
|
|
648
|
+
predicate: (t) => {
|
|
649
|
+
return t.name === 'Reply with exactly: opencode-queue-setup'
|
|
650
|
+
},
|
|
651
|
+
})
|
|
652
|
+
|
|
653
|
+
const th = discord.thread(thread.id)
|
|
654
|
+
const firstReply = await th.waitForBotReply({ timeout: 4_000 })
|
|
655
|
+
expect(firstReply.content.trim().length).toBeGreaterThan(0)
|
|
656
|
+
|
|
657
|
+
// Anchor follow-up on an already-completed first run so footer ordering
|
|
658
|
+
// is deterministic before we assert on the second prompt.
|
|
659
|
+
await waitForFooterMessage({
|
|
660
|
+
discord,
|
|
661
|
+
threadId: thread.id,
|
|
662
|
+
timeout: 4_000,
|
|
663
|
+
})
|
|
664
|
+
|
|
665
|
+
const followupPrompt =
|
|
666
|
+
'Prompt from test: respond with short text for opencode queue mode.'
|
|
667
|
+
|
|
668
|
+
const followupUserMessage = await th.user(TEST_USER_ID).sendMessage({
|
|
669
|
+
content: followupPrompt,
|
|
670
|
+
})
|
|
671
|
+
|
|
672
|
+
// Assert assistant text parts are visible in Discord.
|
|
673
|
+
await waitForBotMessageContaining({
|
|
674
|
+
discord,
|
|
675
|
+
threadId: thread.id,
|
|
676
|
+
userId: TEST_USER_ID,
|
|
677
|
+
text: '⬥ ok',
|
|
678
|
+
afterMessageId: followupUserMessage.id,
|
|
679
|
+
timeout: 4_000,
|
|
680
|
+
})
|
|
681
|
+
|
|
682
|
+
const messagesWithFollowupFooter = await waitForFooterMessage({
|
|
683
|
+
discord,
|
|
684
|
+
threadId: thread.id,
|
|
685
|
+
timeout: 4_000,
|
|
686
|
+
afterMessageIncludes: followupPrompt,
|
|
687
|
+
afterAuthorId: TEST_USER_ID,
|
|
688
|
+
})
|
|
689
|
+
|
|
690
|
+
expect(await th.text()).toMatchInlineSnapshot(`
|
|
691
|
+
"--- from: user (queue-tester)
|
|
692
|
+
Reply with exactly: opencode-queue-setup
|
|
693
|
+
--- from: assistant (TestBot)
|
|
694
|
+
⬥ ok
|
|
695
|
+
*project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
|
|
696
|
+
--- from: user (queue-tester)
|
|
697
|
+
Prompt from test: respond with short text for opencode queue mode.
|
|
698
|
+
--- from: assistant (TestBot)
|
|
699
|
+
⬥ ok
|
|
700
|
+
*project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
|
|
701
|
+
`)
|
|
702
|
+
const followupUserIndex = messagesWithFollowupFooter.findIndex((message) => {
|
|
703
|
+
return message.id === followupUserMessage.id
|
|
704
|
+
})
|
|
705
|
+
const textPartAfterFollowupIndex = messagesWithFollowupFooter.findIndex((message, index) => {
|
|
706
|
+
return (
|
|
707
|
+
index > followupUserIndex &&
|
|
708
|
+
message.author.id === discord.botUserId &&
|
|
709
|
+
message.content.includes('⬥ ok')
|
|
710
|
+
)
|
|
711
|
+
})
|
|
712
|
+
const footerAfterFollowupIndex = messagesWithFollowupFooter.findIndex((message, index) => {
|
|
713
|
+
return (
|
|
714
|
+
index > textPartAfterFollowupIndex &&
|
|
715
|
+
message.author.id === discord.botUserId &&
|
|
716
|
+
message.content.startsWith('*') &&
|
|
717
|
+
message.content.includes('⋅')
|
|
718
|
+
)
|
|
719
|
+
})
|
|
720
|
+
expect(followupUserIndex).toBeGreaterThan(-1)
|
|
721
|
+
expect(textPartAfterFollowupIndex).toBeGreaterThan(followupUserIndex)
|
|
722
|
+
expect(footerAfterFollowupIndex).toBeGreaterThan(textPartAfterFollowupIndex)
|
|
723
|
+
// Normal messages should not populate kimaki local queue.
|
|
724
|
+
const noLocalQueueState = await waitForThreadState({
|
|
725
|
+
threadId: thread.id,
|
|
726
|
+
predicate: (state) => {
|
|
727
|
+
return state.queueItems.length === 0
|
|
728
|
+
},
|
|
729
|
+
timeout: 4_000,
|
|
730
|
+
description: 'local queue remains empty in opencode mode',
|
|
731
|
+
})
|
|
732
|
+
expect(noLocalQueueState.queueItems.length).toBe(0)
|
|
733
|
+
},
|
|
734
|
+
8_000,
|
|
735
|
+
)
|
|
736
|
+
|
|
737
|
+
test(
|
|
738
|
+
'bash tool-call actually executes and creates file in project directory',
|
|
739
|
+
async () => {
|
|
740
|
+
const markerRelativePath = path.join('tmp', 'bash-tool-executed.txt')
|
|
741
|
+
const markerPath = path.join(directories.projectDirectory, markerRelativePath)
|
|
742
|
+
fs.rmSync(markerPath, { force: true })
|
|
743
|
+
|
|
744
|
+
const prompt = 'Reply with exactly: BASH_TOOL_FILE_MARKER'
|
|
745
|
+
await discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
|
|
746
|
+
content: prompt,
|
|
747
|
+
})
|
|
748
|
+
|
|
749
|
+
const thread = await discord.channel(TEXT_CHANNEL_ID).waitForThread({
|
|
750
|
+
timeout: 4_000,
|
|
751
|
+
predicate: (t) => {
|
|
752
|
+
return t.name === prompt
|
|
753
|
+
},
|
|
754
|
+
})
|
|
755
|
+
|
|
756
|
+
await waitForBotMessageContaining({
|
|
757
|
+
discord,
|
|
758
|
+
threadId: thread.id,
|
|
759
|
+
userId: TEST_USER_ID,
|
|
760
|
+
text: 'running create file',
|
|
761
|
+
timeout: 4_000,
|
|
762
|
+
})
|
|
763
|
+
|
|
764
|
+
await waitForFooterMessage({
|
|
765
|
+
discord,
|
|
766
|
+
threadId: thread.id,
|
|
767
|
+
timeout: 4_000,
|
|
768
|
+
})
|
|
769
|
+
|
|
770
|
+
const deadline = Date.now() + 4_000
|
|
771
|
+
while (!fs.existsSync(markerPath) && Date.now() < deadline) {
|
|
772
|
+
await new Promise((resolve) => {
|
|
773
|
+
setTimeout(resolve, 100)
|
|
774
|
+
})
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
expect(await discord.thread(thread.id).text()).toMatchInlineSnapshot(`
|
|
778
|
+
"--- from: user (queue-tester)
|
|
779
|
+
Reply with exactly: BASH_TOOL_FILE_MARKER
|
|
780
|
+
--- from: assistant (TestBot)
|
|
781
|
+
⬥ running create file
|
|
782
|
+
⬥ ok
|
|
783
|
+
*project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
|
|
784
|
+
`)
|
|
785
|
+
expect(fs.existsSync(markerPath)).toBe(true)
|
|
786
|
+
const markerContents = fs.readFileSync(markerPath, 'utf8')
|
|
787
|
+
expect(markerContents).toBe('created')
|
|
788
|
+
},
|
|
789
|
+
8_000,
|
|
790
|
+
)
|
|
791
|
+
|
|
792
|
+
test(
|
|
793
|
+
'/queue shows queued status first, then dispatch indicator when dequeued',
|
|
794
|
+
async () => {
|
|
795
|
+
await discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
|
|
796
|
+
content: 'Reply with exactly: queue-slash-setup',
|
|
797
|
+
})
|
|
798
|
+
|
|
799
|
+
const thread = await discord.channel(TEXT_CHANNEL_ID).waitForThread({
|
|
800
|
+
timeout: 4_000,
|
|
801
|
+
predicate: (t) => {
|
|
802
|
+
return t.name === 'Reply with exactly: queue-slash-setup'
|
|
803
|
+
},
|
|
804
|
+
})
|
|
805
|
+
|
|
806
|
+
const th = discord.thread(thread.id)
|
|
807
|
+
const firstReply = await th.waitForBotReply({ timeout: 4_000 })
|
|
808
|
+
expect(firstReply.content.trim().length).toBeGreaterThan(0)
|
|
809
|
+
|
|
810
|
+
// Ensure the setup run is fully settled before slash-queue checks.
|
|
811
|
+
// Otherwise the first /queue call can race with a still-busy run window.
|
|
812
|
+
await waitForFooterMessage({
|
|
813
|
+
discord,
|
|
814
|
+
threadId: thread.id,
|
|
815
|
+
timeout: 4_000,
|
|
816
|
+
})
|
|
817
|
+
|
|
818
|
+
// Start a non-interrupting queued slash message while idle so it
|
|
819
|
+
// dispatches immediately and keeps the runtime active.
|
|
820
|
+
const { id: firstQueueInteractionId } = await th.user(TEST_USER_ID)
|
|
821
|
+
.runSlashCommand({
|
|
822
|
+
name: 'queue',
|
|
823
|
+
options: [{ name: 'message', type: 3, value: 'Reply with exactly: race-final' }],
|
|
824
|
+
})
|
|
825
|
+
|
|
826
|
+
const firstQueueAck = await th.waitForInteractionAck({
|
|
827
|
+
interactionId: firstQueueInteractionId,
|
|
828
|
+
timeout: 4_000,
|
|
829
|
+
})
|
|
830
|
+
if (!firstQueueAck.messageId) {
|
|
831
|
+
throw new Error('Expected first /queue response message id')
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
const firstQueueAckMessage = await waitForMessageById({
|
|
835
|
+
discord,
|
|
836
|
+
threadId: thread.id,
|
|
837
|
+
messageId: firstQueueAck.messageId,
|
|
838
|
+
timeout: 4_000,
|
|
839
|
+
})
|
|
840
|
+
expect(firstQueueAckMessage.content).toContain('» **queue-tester:** Reply with exactly: race-final')
|
|
841
|
+
|
|
842
|
+
const queuedPrompt = 'Reply with exactly: queued-from-slash'
|
|
843
|
+
const { id: interactionId } = await th.user(TEST_USER_ID).runSlashCommand({
|
|
844
|
+
name: 'queue',
|
|
845
|
+
options: [{ name: 'message', type: 3, value: queuedPrompt }],
|
|
846
|
+
})
|
|
847
|
+
|
|
848
|
+
const queuedAck = await th.waitForInteractionAck({ interactionId, timeout: 4_000 })
|
|
849
|
+
if (!queuedAck.messageId) {
|
|
850
|
+
throw new Error('Expected queued /queue response message id')
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
const queuedStatusMessage = await waitForMessageById({
|
|
854
|
+
discord,
|
|
855
|
+
threadId: thread.id,
|
|
856
|
+
messageId: queuedAck.messageId,
|
|
857
|
+
timeout: 4_000,
|
|
858
|
+
})
|
|
859
|
+
expect(queuedStatusMessage.content.startsWith('Queued message')).toBe(true)
|
|
860
|
+
|
|
861
|
+
const expectedDispatchIndicator = `» **queue-tester:** ${queuedPrompt}`
|
|
862
|
+
const messagesWithDispatch = await waitForBotMessageContaining({
|
|
863
|
+
discord,
|
|
864
|
+
threadId: thread.id,
|
|
865
|
+
userId: TEST_USER_ID,
|
|
866
|
+
text: expectedDispatchIndicator,
|
|
867
|
+
afterMessageId: queuedStatusMessage.id,
|
|
868
|
+
timeout: 8_000,
|
|
869
|
+
})
|
|
870
|
+
|
|
871
|
+
const queuedStatusIndex = messagesWithDispatch.findIndex((message) => {
|
|
872
|
+
return message.id === queuedStatusMessage.id
|
|
873
|
+
})
|
|
874
|
+
const dispatchIndicatorIndex = messagesWithDispatch.findIndex((message) => {
|
|
875
|
+
return (
|
|
876
|
+
message.author.id === discord.botUserId &&
|
|
877
|
+
message.content.includes(expectedDispatchIndicator)
|
|
878
|
+
)
|
|
879
|
+
})
|
|
880
|
+
expect(queuedStatusIndex).toBeGreaterThan(-1)
|
|
881
|
+
expect(dispatchIndicatorIndex).toBeGreaterThan(queuedStatusIndex)
|
|
882
|
+
|
|
883
|
+
const dispatchIndicatorMessage = messagesWithDispatch[dispatchIndicatorIndex]
|
|
884
|
+
if (!dispatchIndicatorMessage) {
|
|
885
|
+
throw new Error('Expected dispatch indicator message')
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
await waitForBotMessageContaining({
|
|
889
|
+
discord,
|
|
890
|
+
threadId: thread.id,
|
|
891
|
+
text: '⬥ ok',
|
|
892
|
+
afterMessageId: dispatchIndicatorMessage.id,
|
|
893
|
+
timeout: 8_000,
|
|
894
|
+
})
|
|
895
|
+
|
|
896
|
+
await waitForFooterMessage({
|
|
897
|
+
discord,
|
|
898
|
+
threadId: thread.id,
|
|
899
|
+
timeout: 8_000,
|
|
900
|
+
afterMessageIncludes: '⬥ ok',
|
|
901
|
+
afterAuthorId: discord.botUserId,
|
|
902
|
+
})
|
|
903
|
+
|
|
904
|
+
expect(await th.text()).toMatchInlineSnapshot(`
|
|
905
|
+
"--- from: user (queue-tester)
|
|
906
|
+
Reply with exactly: queue-slash-setup
|
|
907
|
+
--- from: assistant (TestBot)
|
|
908
|
+
⬥ ok
|
|
909
|
+
*project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
|
|
910
|
+
» **queue-tester:** Reply with exactly: race-final
|
|
911
|
+
Queued message (position 1)
|
|
912
|
+
⬥ race-final
|
|
913
|
+
*project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
|
|
914
|
+
» **queue-tester:** Reply with exactly: queued-from-slash
|
|
915
|
+
⬥ ok
|
|
916
|
+
*project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
|
|
917
|
+
`)
|
|
918
|
+
},
|
|
919
|
+
12_000,
|
|
920
|
+
)
|
|
921
|
+
|
|
922
|
+
test(
|
|
923
|
+
'queued message waits for running session and then processes next',
|
|
924
|
+
async () => {
|
|
925
|
+
// When a new message arrives while a session is running, it queues and
|
|
926
|
+
// runs after the in-flight request completes.
|
|
927
|
+
//
|
|
928
|
+
// 1. Fast setup: establish session
|
|
929
|
+
await discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
|
|
930
|
+
content: 'Reply with exactly: delta',
|
|
931
|
+
})
|
|
932
|
+
|
|
933
|
+
const thread = await discord.channel(TEXT_CHANNEL_ID).waitForThread({
|
|
934
|
+
timeout: 4_000,
|
|
935
|
+
predicate: (t) => {
|
|
936
|
+
return t.name === 'Reply with exactly: delta'
|
|
937
|
+
},
|
|
938
|
+
})
|
|
939
|
+
|
|
940
|
+
const th = discord.thread(thread.id)
|
|
941
|
+
const firstReply = await th.waitForBotReply({ timeout: 4_000 })
|
|
942
|
+
expect(firstReply.content.trim().length).toBeGreaterThan(0)
|
|
943
|
+
|
|
944
|
+
const before = await th.getMessages()
|
|
945
|
+
const beforeBotCount = before.filter((m) => {
|
|
946
|
+
return m.author.id === discord.botUserId
|
|
947
|
+
}).length
|
|
948
|
+
|
|
949
|
+
// 2. Send B, then quickly send C to enqueue behind B.
|
|
950
|
+
await th.user(TEST_USER_ID).sendMessage({
|
|
951
|
+
content: 'Reply with exactly: echo',
|
|
952
|
+
})
|
|
953
|
+
await new Promise((r) => {
|
|
954
|
+
setTimeout(r, 500)
|
|
955
|
+
})
|
|
956
|
+
await th.user(TEST_USER_ID).sendMessage({
|
|
957
|
+
content: 'Reply with exactly: foxtrot',
|
|
958
|
+
})
|
|
959
|
+
|
|
960
|
+
// 3. Poll until foxtrot's user message has a bot reply after it.
|
|
961
|
+
// waitForBotMessageCount alone isn't enough — error messages from the
|
|
962
|
+
// interrupted session can satisfy the count before foxtrot gets its reply.
|
|
963
|
+
const after = await waitForBotReplyAfterUserMessage({
|
|
964
|
+
discord,
|
|
965
|
+
threadId: thread.id,
|
|
966
|
+
userId: TEST_USER_ID,
|
|
967
|
+
userMessageIncludes: 'foxtrot',
|
|
968
|
+
timeout: 4_000,
|
|
969
|
+
})
|
|
970
|
+
|
|
971
|
+
// 4. Foxtrot got a bot response after B/C were processed.
|
|
972
|
+
const afterBotMessages = after.filter((m) => {
|
|
973
|
+
return m.author.id === discord.botUserId
|
|
974
|
+
})
|
|
975
|
+
expect(afterBotMessages.length).toBeGreaterThanOrEqual(beforeBotCount + 1)
|
|
976
|
+
|
|
977
|
+
await waitForFooterMessage({
|
|
978
|
+
discord,
|
|
979
|
+
threadId: thread.id,
|
|
980
|
+
timeout: 4_000,
|
|
981
|
+
afterMessageIncludes: 'foxtrot',
|
|
982
|
+
afterAuthorId: TEST_USER_ID,
|
|
983
|
+
})
|
|
984
|
+
|
|
985
|
+
// Assert ordering invariants instead of exact snapshot — the echo reply
|
|
986
|
+
// and footer can interleave non-deterministically on slower CI hardware.
|
|
987
|
+
const finalMessages = await th.getMessages()
|
|
988
|
+
const userEchoIndex = finalMessages.findIndex((m) => {
|
|
989
|
+
return m.author.id === TEST_USER_ID && m.content.includes('echo')
|
|
990
|
+
})
|
|
991
|
+
const userFoxtrotIndex = finalMessages.findIndex((m) => {
|
|
992
|
+
return m.author.id === TEST_USER_ID && m.content.includes('foxtrot')
|
|
993
|
+
})
|
|
994
|
+
expect(userEchoIndex).toBeGreaterThan(-1)
|
|
995
|
+
expect(userFoxtrotIndex).toBeGreaterThan(-1)
|
|
996
|
+
// User messages appear in send order
|
|
997
|
+
expect(userEchoIndex).toBeLessThan(userFoxtrotIndex)
|
|
998
|
+
|
|
999
|
+
// Foxtrot's bot reply appears after the foxtrot user message
|
|
1000
|
+
const botAfterFoxtrot = finalMessages.findIndex((m, i) => {
|
|
1001
|
+
return i > userFoxtrotIndex && m.author.id === discord.botUserId
|
|
1002
|
+
})
|
|
1003
|
+
expect(botAfterFoxtrot).toBeGreaterThan(userFoxtrotIndex)
|
|
1004
|
+
|
|
1005
|
+
// A footer appears after foxtrot (session completed)
|
|
1006
|
+
const timeline = await th.text()
|
|
1007
|
+
expect(timeline).toContain('Reply with exactly: echo')
|
|
1008
|
+
expect(timeline).toContain('Reply with exactly: foxtrot')
|
|
1009
|
+
expect(timeline).toContain('*project ⋅ main ⋅')
|
|
1010
|
+
},
|
|
1011
|
+
8_000,
|
|
1012
|
+
)
|
|
1013
|
+
|
|
1014
|
+
test(
|
|
1015
|
+
'slow stream still processes queued next message after completion',
|
|
1016
|
+
async () => {
|
|
1017
|
+
// A message sent mid-stream queues and runs after the in-flight request
|
|
1018
|
+
// completes (no auto-interrupt).
|
|
1019
|
+
|
|
1020
|
+
// 1. Fast setup: establish session
|
|
1021
|
+
await discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
|
|
1022
|
+
content: 'Reply with exactly: golf',
|
|
1023
|
+
})
|
|
1024
|
+
|
|
1025
|
+
const thread = await discord.channel(TEXT_CHANNEL_ID).waitForThread({
|
|
1026
|
+
timeout: 4_000,
|
|
1027
|
+
predicate: (t) => {
|
|
1028
|
+
return t.name === 'Reply with exactly: golf'
|
|
1029
|
+
},
|
|
1030
|
+
})
|
|
1031
|
+
|
|
1032
|
+
const th = discord.thread(thread.id)
|
|
1033
|
+
const firstReply = await th.waitForBotReply({ timeout: 4_000 })
|
|
1034
|
+
expect(firstReply.content.trim().length).toBeGreaterThan(0)
|
|
1035
|
+
|
|
1036
|
+
// Wait for golf's footer so the golf→hotel transition is deterministic
|
|
1037
|
+
await waitForFooterMessage({
|
|
1038
|
+
discord,
|
|
1039
|
+
threadId: thread.id,
|
|
1040
|
+
timeout: 4_000,
|
|
1041
|
+
afterMessageIncludes: 'ok',
|
|
1042
|
+
afterAuthorId: discord.botUserId,
|
|
1043
|
+
})
|
|
1044
|
+
|
|
1045
|
+
const before = await th.getMessages()
|
|
1046
|
+
const beforeBotCount = before.filter((m) => {
|
|
1047
|
+
return m.author.id === discord.botUserId
|
|
1048
|
+
}).length
|
|
1049
|
+
|
|
1050
|
+
// 2. Start request B (hotel, slow matcher ~400ms), then send C while B
|
|
1051
|
+
// is still in progress.
|
|
1052
|
+
await th.user(TEST_USER_ID).sendMessage({
|
|
1053
|
+
content: 'Reply with exactly: hotel',
|
|
1054
|
+
})
|
|
1055
|
+
|
|
1056
|
+
// 3. Wait briefly for B to start, then send C to queue behind it
|
|
1057
|
+
await new Promise((r) => {
|
|
1058
|
+
setTimeout(r, 200)
|
|
1059
|
+
})
|
|
1060
|
+
await th.user(TEST_USER_ID).sendMessage({
|
|
1061
|
+
content: 'Reply with exactly: india',
|
|
1062
|
+
})
|
|
1063
|
+
|
|
1064
|
+
// 4. B completes, then C gets processed.
|
|
1065
|
+
// Poll until india's user message has a bot reply after it.
|
|
1066
|
+
const after = await waitForBotReplyAfterUserMessage({
|
|
1067
|
+
discord,
|
|
1068
|
+
threadId: thread.id,
|
|
1069
|
+
userId: TEST_USER_ID,
|
|
1070
|
+
userMessageIncludes: 'india',
|
|
1071
|
+
timeout: 4_000,
|
|
1072
|
+
})
|
|
1073
|
+
|
|
1074
|
+
await waitForFooterMessage({
|
|
1075
|
+
discord,
|
|
1076
|
+
threadId: thread.id,
|
|
1077
|
+
timeout: 4_000,
|
|
1078
|
+
afterMessageIncludes: 'india',
|
|
1079
|
+
afterAuthorId: TEST_USER_ID,
|
|
1080
|
+
})
|
|
1081
|
+
|
|
1082
|
+
// C's user message appears before its bot response.
|
|
1083
|
+
// We assert on india's reply existence.
|
|
1084
|
+
expect(await th.text()).toMatchInlineSnapshot(`
|
|
1085
|
+
"--- from: user (queue-tester)
|
|
1086
|
+
Reply with exactly: golf
|
|
1087
|
+
--- from: assistant (TestBot)
|
|
1088
|
+
⬥ ok
|
|
1089
|
+
*project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
|
|
1090
|
+
--- from: user (queue-tester)
|
|
1091
|
+
Reply with exactly: hotel
|
|
1092
|
+
Reply with exactly: india
|
|
1093
|
+
--- from: assistant (TestBot)
|
|
1094
|
+
⬥ ok
|
|
1095
|
+
⬥ ok
|
|
1096
|
+
*project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
|
|
1097
|
+
`)
|
|
1098
|
+
const userIndiaIndex = after.findIndex((m) => {
|
|
1099
|
+
return m.author.id === TEST_USER_ID && m.content.includes('india')
|
|
1100
|
+
})
|
|
1101
|
+
expect(userIndiaIndex).toBeGreaterThan(-1)
|
|
1102
|
+
const botAfterIndia = after.findIndex((m, i) => {
|
|
1103
|
+
return i > userIndiaIndex && m.author.id === discord.botUserId
|
|
1104
|
+
})
|
|
1105
|
+
expect(botAfterIndia).toBeGreaterThan(userIndiaIndex)
|
|
1106
|
+
},
|
|
1107
|
+
8_000,
|
|
1108
|
+
)
|
|
1109
|
+
|
|
1110
|
+
test(
|
|
1111
|
+
'queue drains correctly after bursty queued messages',
|
|
1112
|
+
async () => {
|
|
1113
|
+
// Verifies the queue doesn't get stuck after multiple rapid messages.
|
|
1114
|
+
|
|
1115
|
+
// 1. Fast setup: establish session
|
|
1116
|
+
await discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
|
|
1117
|
+
content: 'Reply with exactly: juliet',
|
|
1118
|
+
})
|
|
1119
|
+
|
|
1120
|
+
const thread = await discord.channel(TEXT_CHANNEL_ID).waitForThread({
|
|
1121
|
+
timeout: 4_000,
|
|
1122
|
+
predicate: (t) => {
|
|
1123
|
+
return t.name === 'Reply with exactly: juliet'
|
|
1124
|
+
},
|
|
1125
|
+
})
|
|
1126
|
+
|
|
1127
|
+
const th = discord.thread(thread.id)
|
|
1128
|
+
const firstReply = await th.waitForBotReply({ timeout: 4_000 })
|
|
1129
|
+
expect(firstReply.content.trim().length).toBeGreaterThan(0)
|
|
1130
|
+
|
|
1131
|
+
const before = await th.getMessages()
|
|
1132
|
+
const beforeBotCount = before.filter((m) => {
|
|
1133
|
+
return m.author.id === discord.botUserId
|
|
1134
|
+
}).length
|
|
1135
|
+
|
|
1136
|
+
// 2. Rapidly send B, C, D back-to-back to avoid timing windows where
|
|
1137
|
+
// one run can finish between sends and reorder transcript lines.
|
|
1138
|
+
await th.user(TEST_USER_ID).sendMessage({
|
|
1139
|
+
content: 'Reply with exactly: kilo',
|
|
1140
|
+
})
|
|
1141
|
+
await th.user(TEST_USER_ID).sendMessage({
|
|
1142
|
+
content: 'Reply with exactly: lima',
|
|
1143
|
+
})
|
|
1144
|
+
await th.user(TEST_USER_ID).sendMessage({
|
|
1145
|
+
content: 'Reply with exactly: mike',
|
|
1146
|
+
})
|
|
1147
|
+
|
|
1148
|
+
// 3. Wait until the last burst message (mike) has a bot reply after it.
|
|
1149
|
+
const afterBurst = await waitForBotReplyAfterUserMessage({
|
|
1150
|
+
discord,
|
|
1151
|
+
threadId: thread.id,
|
|
1152
|
+
userId: TEST_USER_ID,
|
|
1153
|
+
userMessageIncludes: 'mike',
|
|
1154
|
+
timeout: 4_000,
|
|
1155
|
+
})
|
|
1156
|
+
|
|
1157
|
+
// 4. Queue should be clean — send E and verify it also gets processed
|
|
1158
|
+
await th.user(TEST_USER_ID).sendMessage({
|
|
1159
|
+
content: 'Reply with exactly: november',
|
|
1160
|
+
})
|
|
1161
|
+
|
|
1162
|
+
const afterE = await waitForBotReplyAfterUserMessage({
|
|
1163
|
+
discord,
|
|
1164
|
+
threadId: thread.id,
|
|
1165
|
+
userId: TEST_USER_ID,
|
|
1166
|
+
userMessageIncludes: 'november',
|
|
1167
|
+
timeout: 4_000,
|
|
1168
|
+
})
|
|
1169
|
+
|
|
1170
|
+
const textWithoutFooters = (await th.text())
|
|
1171
|
+
.split('\n')
|
|
1172
|
+
.filter((line) => {
|
|
1173
|
+
return !line.startsWith('*project ⋅')
|
|
1174
|
+
})
|
|
1175
|
+
.join('\n')
|
|
1176
|
+
|
|
1177
|
+
const normalizedTextWithoutFooters = textWithoutFooters.replace(
|
|
1178
|
+
[
|
|
1179
|
+
'--- from: assistant (TestBot)',
|
|
1180
|
+
'⬥ ok',
|
|
1181
|
+
'--- from: user (queue-tester)',
|
|
1182
|
+
'Reply with exactly: november',
|
|
1183
|
+
].join('\n'),
|
|
1184
|
+
[
|
|
1185
|
+
'--- from: assistant (TestBot)',
|
|
1186
|
+
'--- from: user (queue-tester)',
|
|
1187
|
+
'Reply with exactly: november',
|
|
1188
|
+
].join('\n'),
|
|
1189
|
+
)
|
|
1190
|
+
|
|
1191
|
+
expect(normalizedTextWithoutFooters).toMatchInlineSnapshot(`
|
|
1192
|
+
"--- from: user (queue-tester)
|
|
1193
|
+
Reply with exactly: juliet
|
|
1194
|
+
--- from: assistant (TestBot)
|
|
1195
|
+
⬥ ok
|
|
1196
|
+
--- from: user (queue-tester)
|
|
1197
|
+
Reply with exactly: kilo
|
|
1198
|
+
Reply with exactly: lima
|
|
1199
|
+
Reply with exactly: mike
|
|
1200
|
+
--- from: assistant (TestBot)
|
|
1201
|
+
--- from: user (queue-tester)
|
|
1202
|
+
Reply with exactly: november
|
|
1203
|
+
--- from: assistant (TestBot)
|
|
1204
|
+
⬥ ok"
|
|
1205
|
+
`)
|
|
1206
|
+
// E's user message appears before the final bot response
|
|
1207
|
+
const userNovemberIndex = afterE.findIndex((m) => {
|
|
1208
|
+
return m.author.id === TEST_USER_ID && m.content.includes('november')
|
|
1209
|
+
})
|
|
1210
|
+
expect(userNovemberIndex).toBeGreaterThan(-1)
|
|
1211
|
+
const lastBotIndex = afterE.findLastIndex((m) => {
|
|
1212
|
+
return m.author.id === discord.botUserId
|
|
1213
|
+
})
|
|
1214
|
+
expect(userNovemberIndex).toBeLessThan(lastBotIndex)
|
|
1215
|
+
},
|
|
1216
|
+
8_000,
|
|
1217
|
+
)
|
|
1218
|
+
|
|
1219
|
+
})
|