@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,1255 @@
|
|
|
1
|
+
// E2e tests for voice message handling (audio attachment transcription).
|
|
2
|
+
// Uses deterministic transcription (store.test.deterministicTranscription) to
|
|
3
|
+
// bypass real AI model calls and control transcription output, timing, and
|
|
4
|
+
// queueMessage flag. Combined with opencode-deterministic-provider for session
|
|
5
|
+
// responses. Tests validate the full flow: attachment detection → transcription
|
|
6
|
+
// → session dispatch, including interrupt, queue, and race condition scenarios.
|
|
7
|
+
//
|
|
8
|
+
// Tests assert on both Discord messages (via digital twin) and session state
|
|
9
|
+
// transitions (via getThreadState from the zustand store).
|
|
10
|
+
|
|
11
|
+
import fs from 'node:fs'
|
|
12
|
+
|
|
13
|
+
import path from 'node:path'
|
|
14
|
+
import url from 'node:url'
|
|
15
|
+
import { describe, beforeAll, afterAll, beforeEach, 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 { setDataDir } from './config.js'
|
|
23
|
+
import { store, type DeterministicTranscriptionConfig } from './store.js'
|
|
24
|
+
import { startDiscordBot } from './discord-bot.js'
|
|
25
|
+
import {
|
|
26
|
+
setBotToken,
|
|
27
|
+
initDatabase,
|
|
28
|
+
closeDatabase,
|
|
29
|
+
setChannelDirectory,
|
|
30
|
+
setChannelVerbosity,
|
|
31
|
+
} from './database.js'
|
|
32
|
+
import { startHranaServer, stopHranaServer } from './hrana-server.js'
|
|
33
|
+
import { initializeOpencodeForDirectory, getOpencodeClient, stopOpencodeServer } from './opencode.js'
|
|
34
|
+
import type { Part, Message } from '@opencode-ai/sdk/v2'
|
|
35
|
+
import {
|
|
36
|
+
chooseLockPort,
|
|
37
|
+
cleanupTestSessions,
|
|
38
|
+
initTestGitRepo,
|
|
39
|
+
waitForFooterMessage,
|
|
40
|
+
waitForBotMessageContaining,
|
|
41
|
+
waitForThreadState,
|
|
42
|
+
} from './test-utils.js'
|
|
43
|
+
|
|
44
|
+
import { getThreadState } from './session-handler/thread-runtime-state.js'
|
|
45
|
+
|
|
46
|
+
const e2eTest = describe
|
|
47
|
+
|
|
48
|
+
// ── Helpers ──────────────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
function createRunDirectories() {
|
|
51
|
+
const root = path.resolve(process.cwd(), 'tmp', 'voice-msg-e2e')
|
|
52
|
+
fs.mkdirSync(root, { recursive: true })
|
|
53
|
+
|
|
54
|
+
const dataDir = fs.mkdtempSync(path.join(root, 'data-'))
|
|
55
|
+
const projectDirectory = path.join(root, 'project')
|
|
56
|
+
fs.mkdirSync(projectDirectory, { recursive: true })
|
|
57
|
+
initTestGitRepo(projectDirectory)
|
|
58
|
+
|
|
59
|
+
return { root, dataDir, projectDirectory }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function createDiscordJsClient({ restUrl }: { restUrl: string }) {
|
|
63
|
+
return new Client({
|
|
64
|
+
intents: [
|
|
65
|
+
GatewayIntentBits.Guilds,
|
|
66
|
+
GatewayIntentBits.GuildMessages,
|
|
67
|
+
GatewayIntentBits.MessageContent,
|
|
68
|
+
GatewayIntentBits.GuildVoiceStates,
|
|
69
|
+
],
|
|
70
|
+
partials: [
|
|
71
|
+
Partials.Channel,
|
|
72
|
+
Partials.Message,
|
|
73
|
+
Partials.User,
|
|
74
|
+
Partials.ThreadMember,
|
|
75
|
+
],
|
|
76
|
+
rest: {
|
|
77
|
+
api: restUrl,
|
|
78
|
+
version: '10',
|
|
79
|
+
},
|
|
80
|
+
})
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Set the deterministic transcription config in the store for the next voice message. */
|
|
84
|
+
function setDeterministicTranscription(config: DeterministicTranscriptionConfig | null) {
|
|
85
|
+
store.setState({
|
|
86
|
+
test: { deterministicTranscription: config },
|
|
87
|
+
})
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ── OpenCode session assertion helpers ───────────────────────────
|
|
91
|
+
// These verify what actually happened in the OpenCode session (prompts
|
|
92
|
+
// sent, aborts, responses) beyond just Discord messages and thread state.
|
|
93
|
+
|
|
94
|
+
type SessionMessage = { info: Message; parts: Part[] }
|
|
95
|
+
|
|
96
|
+
function getOpencodeClientForTest(projectDirectory: string) {
|
|
97
|
+
const client = getOpencodeClient(projectDirectory)
|
|
98
|
+
if (!client) {
|
|
99
|
+
throw new Error('OpenCode client not found for project directory')
|
|
100
|
+
}
|
|
101
|
+
return client
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Extract text content from an array of parts (filters to TextPart only). */
|
|
105
|
+
function getTextFromParts(parts: Part[]): string[] {
|
|
106
|
+
return parts.flatMap((part) => {
|
|
107
|
+
if (part.type === 'text') {
|
|
108
|
+
return [part.text]
|
|
109
|
+
}
|
|
110
|
+
return []
|
|
111
|
+
})
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Get all user-role messages' text parts joined. */
|
|
115
|
+
function getUserTexts(messages: SessionMessage[]): string[] {
|
|
116
|
+
return messages
|
|
117
|
+
.filter((m) => m.info.role === 'user')
|
|
118
|
+
.flatMap((m) => getTextFromParts(m.parts))
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Get all assistant-role messages' text parts joined. */
|
|
122
|
+
function getAssistantTexts(messages: SessionMessage[]): string[] {
|
|
123
|
+
return messages
|
|
124
|
+
.filter((m) => m.info.role === 'assistant')
|
|
125
|
+
.flatMap((m) => getTextFromParts(m.parts))
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Poll session.messages() until predicate returns true.
|
|
130
|
+
* Used to wait for async session updates (prompts dispatched, responses completed).
|
|
131
|
+
*/
|
|
132
|
+
async function waitForSessionMessages({
|
|
133
|
+
projectDirectory,
|
|
134
|
+
sessionID,
|
|
135
|
+
timeout,
|
|
136
|
+
predicate,
|
|
137
|
+
description,
|
|
138
|
+
}: {
|
|
139
|
+
projectDirectory: string
|
|
140
|
+
sessionID: string
|
|
141
|
+
timeout: number
|
|
142
|
+
predicate: (messages: SessionMessage[]) => boolean
|
|
143
|
+
description: string
|
|
144
|
+
}): Promise<SessionMessage[]> {
|
|
145
|
+
const client = getOpencodeClientForTest(projectDirectory)
|
|
146
|
+
const start = Date.now()
|
|
147
|
+
while (Date.now() - start < timeout) {
|
|
148
|
+
const result = await client.session.messages({
|
|
149
|
+
sessionID,
|
|
150
|
+
directory: projectDirectory,
|
|
151
|
+
})
|
|
152
|
+
const messages = result.data ?? []
|
|
153
|
+
if (predicate(messages)) {
|
|
154
|
+
return messages
|
|
155
|
+
}
|
|
156
|
+
await new Promise((resolve) => {
|
|
157
|
+
setTimeout(resolve, 100)
|
|
158
|
+
})
|
|
159
|
+
}
|
|
160
|
+
// Final attempt for error reporting
|
|
161
|
+
const finalResult = await client.session.messages({
|
|
162
|
+
sessionID,
|
|
163
|
+
directory: projectDirectory,
|
|
164
|
+
})
|
|
165
|
+
const finalMessages = finalResult.data ?? []
|
|
166
|
+
const userTexts = getUserTexts(finalMessages)
|
|
167
|
+
const assistantTexts = getAssistantTexts(finalMessages)
|
|
168
|
+
throw new Error(
|
|
169
|
+
`Timed out waiting for session messages (${description}). ` +
|
|
170
|
+
`User texts: ${JSON.stringify(userTexts.map((t) => t.slice(0, 80)))}. ` +
|
|
171
|
+
`Assistant texts: ${JSON.stringify(assistantTexts.map((t) => t.slice(0, 80)))}`,
|
|
172
|
+
)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ── Deterministic provider matchers ──────────────────────────────
|
|
176
|
+
// The opencode session uses these to produce canned responses.
|
|
177
|
+
|
|
178
|
+
function createDeterministicMatchers(): DeterministicMatcher[] {
|
|
179
|
+
// Slow response: emits text-delta after 2s delay, giving voice messages
|
|
180
|
+
// time to arrive while the session is still "running".
|
|
181
|
+
// Uses latestUserTextIncludes (not rawPromptIncludes) so it only matches
|
|
182
|
+
// the current user message, not previous messages in session history.
|
|
183
|
+
const slowResponse: DeterministicMatcher = {
|
|
184
|
+
id: 'slow-response',
|
|
185
|
+
priority: 100,
|
|
186
|
+
when: {
|
|
187
|
+
latestUserTextIncludes: 'SLOW_RESPONSE_MARKER',
|
|
188
|
+
},
|
|
189
|
+
then: {
|
|
190
|
+
parts: [
|
|
191
|
+
{ type: 'stream-start', warnings: [] },
|
|
192
|
+
{ type: 'text-start', id: 'slow' },
|
|
193
|
+
{ type: 'text-delta', id: 'slow', delta: 'slow-response-done' },
|
|
194
|
+
{ type: 'text-end', id: 'slow' },
|
|
195
|
+
{
|
|
196
|
+
type: 'finish',
|
|
197
|
+
finishReason: 'stop',
|
|
198
|
+
usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 },
|
|
199
|
+
},
|
|
200
|
+
],
|
|
201
|
+
// 2s delay on the first text delta — keeps the session in "running" state
|
|
202
|
+
partDelaysMs: [0, 0, 2000, 0, 0],
|
|
203
|
+
},
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Fast response: completes almost immediately (~100ms)
|
|
207
|
+
const fastResponse: DeterministicMatcher = {
|
|
208
|
+
id: 'fast-response',
|
|
209
|
+
priority: 90,
|
|
210
|
+
when: {
|
|
211
|
+
latestUserTextIncludes: 'FAST_RESPONSE_MARKER',
|
|
212
|
+
},
|
|
213
|
+
then: {
|
|
214
|
+
parts: [
|
|
215
|
+
{ type: 'stream-start', warnings: [] },
|
|
216
|
+
{ type: 'text-start', id: 'fast' },
|
|
217
|
+
{ type: 'text-delta', id: 'fast', delta: 'fast-response-done' },
|
|
218
|
+
{ type: 'text-end', id: 'fast' },
|
|
219
|
+
{
|
|
220
|
+
type: 'finish',
|
|
221
|
+
finishReason: 'stop',
|
|
222
|
+
usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 },
|
|
223
|
+
},
|
|
224
|
+
],
|
|
225
|
+
partDelaysMs: [0, 100, 0, 0, 0],
|
|
226
|
+
},
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Default: matches any user message (fallback)
|
|
230
|
+
const defaultReply: DeterministicMatcher = {
|
|
231
|
+
id: 'default-reply',
|
|
232
|
+
priority: 1,
|
|
233
|
+
when: {
|
|
234
|
+
lastMessageRole: 'user',
|
|
235
|
+
},
|
|
236
|
+
then: {
|
|
237
|
+
parts: [
|
|
238
|
+
{ type: 'stream-start', warnings: [] },
|
|
239
|
+
{ type: 'text-start', id: 'default' },
|
|
240
|
+
{ type: 'text-delta', id: 'default', delta: 'session-reply' },
|
|
241
|
+
{ type: 'text-end', id: 'default' },
|
|
242
|
+
{
|
|
243
|
+
type: 'finish',
|
|
244
|
+
finishReason: 'stop',
|
|
245
|
+
usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 },
|
|
246
|
+
},
|
|
247
|
+
],
|
|
248
|
+
partDelaysMs: [0, 100, 0, 0, 0],
|
|
249
|
+
},
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Tool followup: when the last message is a tool result
|
|
253
|
+
const toolFollowup: DeterministicMatcher = {
|
|
254
|
+
id: 'tool-followup',
|
|
255
|
+
priority: 50,
|
|
256
|
+
when: {
|
|
257
|
+
lastMessageRole: 'tool',
|
|
258
|
+
},
|
|
259
|
+
then: {
|
|
260
|
+
parts: [
|
|
261
|
+
{ type: 'stream-start', warnings: [] },
|
|
262
|
+
{ type: 'text-start', id: 'tool-followup' },
|
|
263
|
+
{ type: 'text-delta', id: 'tool-followup', delta: 'tool done' },
|
|
264
|
+
{ type: 'text-end', id: 'tool-followup' },
|
|
265
|
+
{
|
|
266
|
+
type: 'finish',
|
|
267
|
+
finishReason: 'stop',
|
|
268
|
+
usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 },
|
|
269
|
+
},
|
|
270
|
+
],
|
|
271
|
+
},
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
return [slowResponse, fastResponse, toolFollowup, defaultReply]
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// ── Test constants ───────────────────────────────────────────────
|
|
278
|
+
|
|
279
|
+
const TEST_USER_ID = '300000000000000777'
|
|
280
|
+
const TEXT_CHANNEL_ID = '300000000000000778'
|
|
281
|
+
|
|
282
|
+
// ── Test suite ───────────────────────────────────────────────────
|
|
283
|
+
|
|
284
|
+
e2eTest('voice message handling', () => {
|
|
285
|
+
let directories: ReturnType<typeof createRunDirectories>
|
|
286
|
+
let discord: DigitalDiscord
|
|
287
|
+
let botClient: Client
|
|
288
|
+
let previousDefaultVerbosity = store.getState().defaultVerbosity
|
|
289
|
+
let testStartTime = Date.now()
|
|
290
|
+
|
|
291
|
+
beforeAll(async () => {
|
|
292
|
+
testStartTime = Date.now()
|
|
293
|
+
directories = createRunDirectories()
|
|
294
|
+
const lockPort = chooseLockPort({ key: TEXT_CHANNEL_ID })
|
|
295
|
+
|
|
296
|
+
process.env['KIMAKI_LOCK_PORT'] = String(lockPort)
|
|
297
|
+
setDataDir(directories.dataDir)
|
|
298
|
+
previousDefaultVerbosity = store.getState().defaultVerbosity
|
|
299
|
+
store.setState({ defaultVerbosity: 'tools_and_text' })
|
|
300
|
+
|
|
301
|
+
const digitalDiscordDbPath = path.join(
|
|
302
|
+
directories.dataDir,
|
|
303
|
+
'digital-discord.db',
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
discord = new DigitalDiscord({
|
|
307
|
+
guild: {
|
|
308
|
+
name: 'Voice E2E Guild',
|
|
309
|
+
ownerId: TEST_USER_ID,
|
|
310
|
+
},
|
|
311
|
+
channels: [
|
|
312
|
+
{
|
|
313
|
+
id: TEXT_CHANNEL_ID,
|
|
314
|
+
name: 'voice-e2e',
|
|
315
|
+
type: ChannelType.GuildText,
|
|
316
|
+
},
|
|
317
|
+
],
|
|
318
|
+
users: [
|
|
319
|
+
{
|
|
320
|
+
id: TEST_USER_ID,
|
|
321
|
+
username: 'voice-tester',
|
|
322
|
+
},
|
|
323
|
+
],
|
|
324
|
+
dbUrl: `file:${digitalDiscordDbPath}`,
|
|
325
|
+
})
|
|
326
|
+
|
|
327
|
+
await discord.start()
|
|
328
|
+
|
|
329
|
+
const providerNpm = url
|
|
330
|
+
.pathToFileURL(
|
|
331
|
+
path.resolve(
|
|
332
|
+
process.cwd(),
|
|
333
|
+
'..',
|
|
334
|
+
'opencode-deterministic-provider',
|
|
335
|
+
'src',
|
|
336
|
+
'index.ts',
|
|
337
|
+
),
|
|
338
|
+
)
|
|
339
|
+
.toString()
|
|
340
|
+
|
|
341
|
+
const opencodeConfig = buildDeterministicOpencodeConfig({
|
|
342
|
+
providerName: 'deterministic-provider',
|
|
343
|
+
providerNpm,
|
|
344
|
+
model: 'deterministic-v2',
|
|
345
|
+
smallModel: 'deterministic-v2',
|
|
346
|
+
settings: {
|
|
347
|
+
strict: false,
|
|
348
|
+
matchers: createDeterministicMatchers(),
|
|
349
|
+
},
|
|
350
|
+
})
|
|
351
|
+
fs.writeFileSync(
|
|
352
|
+
path.join(directories.projectDirectory, 'opencode.json'),
|
|
353
|
+
JSON.stringify(opencodeConfig, null, 2),
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
const dbPath = path.join(directories.dataDir, 'discord-sessions.db')
|
|
357
|
+
const hranaResult = await startHranaServer({ dbPath })
|
|
358
|
+
if (hranaResult instanceof Error) {
|
|
359
|
+
throw hranaResult
|
|
360
|
+
}
|
|
361
|
+
process.env['KIMAKI_DB_URL'] = hranaResult
|
|
362
|
+
await initDatabase()
|
|
363
|
+
await setBotToken(discord.botUserId, discord.botToken)
|
|
364
|
+
|
|
365
|
+
await setChannelDirectory({
|
|
366
|
+
channelId: TEXT_CHANNEL_ID,
|
|
367
|
+
directory: directories.projectDirectory,
|
|
368
|
+
channelType: 'text',
|
|
369
|
+
})
|
|
370
|
+
await setChannelVerbosity(TEXT_CHANNEL_ID, 'tools_and_text')
|
|
371
|
+
|
|
372
|
+
botClient = createDiscordJsClient({ restUrl: discord.restUrl })
|
|
373
|
+
await startDiscordBot({
|
|
374
|
+
token: discord.botToken,
|
|
375
|
+
appId: discord.botUserId,
|
|
376
|
+
discordClient: botClient,
|
|
377
|
+
})
|
|
378
|
+
|
|
379
|
+
// Pre-warm the opencode server
|
|
380
|
+
const warmup = await initializeOpencodeForDirectory(
|
|
381
|
+
directories.projectDirectory,
|
|
382
|
+
)
|
|
383
|
+
if (warmup instanceof Error) {
|
|
384
|
+
throw warmup
|
|
385
|
+
}
|
|
386
|
+
}, 60_000)
|
|
387
|
+
|
|
388
|
+
afterAll(async () => {
|
|
389
|
+
// Reset deterministic transcription
|
|
390
|
+
setDeterministicTranscription(null)
|
|
391
|
+
|
|
392
|
+
if (directories) {
|
|
393
|
+
await cleanupTestSessions({
|
|
394
|
+
projectDirectory: directories.projectDirectory,
|
|
395
|
+
testStartTime,
|
|
396
|
+
})
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
if (botClient) {
|
|
400
|
+
botClient.destroy()
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
await stopOpencodeServer()
|
|
404
|
+
await Promise.all([
|
|
405
|
+
closeDatabase().catch(() => {
|
|
406
|
+
return
|
|
407
|
+
}),
|
|
408
|
+
stopHranaServer().catch(() => {
|
|
409
|
+
return
|
|
410
|
+
}),
|
|
411
|
+
discord?.stop().catch(() => {
|
|
412
|
+
return
|
|
413
|
+
}),
|
|
414
|
+
])
|
|
415
|
+
|
|
416
|
+
delete process.env['KIMAKI_LOCK_PORT']
|
|
417
|
+
delete process.env['KIMAKI_DB_URL']
|
|
418
|
+
store.setState({ defaultVerbosity: previousDefaultVerbosity })
|
|
419
|
+
if (directories) {
|
|
420
|
+
fs.rmSync(directories.dataDir, { recursive: true, force: true })
|
|
421
|
+
}
|
|
422
|
+
}, 10_000)
|
|
423
|
+
|
|
424
|
+
beforeEach(() => {
|
|
425
|
+
// Reset deterministic transcription before each test to prevent leakage
|
|
426
|
+
// from a failed test that set it but didn't clean up
|
|
427
|
+
setDeterministicTranscription(null)
|
|
428
|
+
})
|
|
429
|
+
|
|
430
|
+
// ── Test 1: Voice message in a channel creates thread + session ──
|
|
431
|
+
|
|
432
|
+
test(
|
|
433
|
+
'voice message in channel creates thread and starts session',
|
|
434
|
+
async () => {
|
|
435
|
+
setDeterministicTranscription({
|
|
436
|
+
transcription: 'Fix the login bug in auth.ts',
|
|
437
|
+
queueMessage: false,
|
|
438
|
+
})
|
|
439
|
+
|
|
440
|
+
// Send voice message in the text channel
|
|
441
|
+
await discord
|
|
442
|
+
.channel(TEXT_CHANNEL_ID)
|
|
443
|
+
.user(TEST_USER_ID)
|
|
444
|
+
.sendVoiceMessage()
|
|
445
|
+
|
|
446
|
+
// Thread should be created and renamed to the transcription text
|
|
447
|
+
const thread = await discord.channel(TEXT_CHANNEL_ID).waitForThread({
|
|
448
|
+
timeout: 4_000,
|
|
449
|
+
predicate: (t) => {
|
|
450
|
+
return t.name?.includes('Fix the login bug') ?? false
|
|
451
|
+
},
|
|
452
|
+
})
|
|
453
|
+
expect(thread).toBeDefined()
|
|
454
|
+
|
|
455
|
+
const th = discord.thread(thread.id)
|
|
456
|
+
|
|
457
|
+
// Bot should post "Transcribing..." then "Transcribed message: ..."
|
|
458
|
+
await waitForBotMessageContaining({
|
|
459
|
+
discord,
|
|
460
|
+
threadId: thread.id,
|
|
461
|
+
userId: TEST_USER_ID,
|
|
462
|
+
text: 'Transcribing voice message',
|
|
463
|
+
timeout: 4_000,
|
|
464
|
+
})
|
|
465
|
+
|
|
466
|
+
await waitForBotMessageContaining({
|
|
467
|
+
discord,
|
|
468
|
+
threadId: thread.id,
|
|
469
|
+
userId: TEST_USER_ID,
|
|
470
|
+
text: 'Fix the login bug in auth.ts',
|
|
471
|
+
timeout: 4_000,
|
|
472
|
+
})
|
|
473
|
+
|
|
474
|
+
// Session should get the transcribed prompt and respond
|
|
475
|
+
const sessionReply = await th.waitForBotReply({ timeout: 4_000 })
|
|
476
|
+
expect(sessionReply).toBeDefined()
|
|
477
|
+
|
|
478
|
+
await waitForFooterMessage({
|
|
479
|
+
discord,
|
|
480
|
+
threadId: thread.id,
|
|
481
|
+
timeout: 4_000,
|
|
482
|
+
})
|
|
483
|
+
|
|
484
|
+
// Assert thread state has a session and no queued messages after footer.
|
|
485
|
+
const finalState = await waitForThreadState({
|
|
486
|
+
threadId: thread.id,
|
|
487
|
+
predicate: (state) => {
|
|
488
|
+
return Boolean(state.sessionId) && state.queueItems.length === 0
|
|
489
|
+
},
|
|
490
|
+
timeout: 4_000,
|
|
491
|
+
description: 'voice turn settled with empty queue',
|
|
492
|
+
})
|
|
493
|
+
|
|
494
|
+
await waitForFooterMessage({
|
|
495
|
+
discord,
|
|
496
|
+
threadId: thread.id,
|
|
497
|
+
timeout: 4_000,
|
|
498
|
+
})
|
|
499
|
+
|
|
500
|
+
expect(await th.text()).toMatchInlineSnapshot(`
|
|
501
|
+
"--- from: user (voice-tester)
|
|
502
|
+
[attachment: voice-message.ogg]
|
|
503
|
+
--- from: assistant (TestBot)
|
|
504
|
+
🎤 Transcribing voice message...
|
|
505
|
+
📝 **Transcribed message:** Fix the login bug in auth.ts
|
|
506
|
+
⬥ session-reply
|
|
507
|
+
*project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
|
|
508
|
+
`)
|
|
509
|
+
expect(finalState.sessionId).toBeDefined()
|
|
510
|
+
|
|
511
|
+
// Verify OpenCode session received the transcribed voice message as a prompt
|
|
512
|
+
const messages = await waitForSessionMessages({
|
|
513
|
+
projectDirectory: directories.projectDirectory,
|
|
514
|
+
sessionID: finalState.sessionId!,
|
|
515
|
+
timeout: 4_000,
|
|
516
|
+
description: 'voice transcription prompt sent to session',
|
|
517
|
+
predicate: (all) => {
|
|
518
|
+
const userTexts = getUserTexts(all)
|
|
519
|
+
return userTexts.some((text) => text.includes('Fix the login bug in auth.ts'))
|
|
520
|
+
},
|
|
521
|
+
})
|
|
522
|
+
const userTexts = getUserTexts(messages)
|
|
523
|
+
expect(userTexts.some((t) => t.includes('Fix the login bug in auth.ts'))).toBe(true)
|
|
524
|
+
// Session should have at least one assistant response
|
|
525
|
+
const assistantTexts = getAssistantTexts(messages)
|
|
526
|
+
expect(assistantTexts.length).toBeGreaterThan(0)
|
|
527
|
+
},
|
|
528
|
+
8_000,
|
|
529
|
+
)
|
|
530
|
+
|
|
531
|
+
test(
|
|
532
|
+
'voice attachment without content type still transcribes and avoids empty prompt dispatch',
|
|
533
|
+
async () => {
|
|
534
|
+
setDeterministicTranscription({
|
|
535
|
+
transcription: 'Investigate the missing content type path',
|
|
536
|
+
queueMessage: false,
|
|
537
|
+
})
|
|
538
|
+
|
|
539
|
+
await discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
|
|
540
|
+
content: '',
|
|
541
|
+
attachments: [
|
|
542
|
+
{
|
|
543
|
+
id: 'voice-no-content-type',
|
|
544
|
+
filename: 'voice-message.ogg',
|
|
545
|
+
size: 1024,
|
|
546
|
+
url: 'https://fake-cdn.discord.test/voice-no-content-type.ogg',
|
|
547
|
+
proxy_url: 'https://fake-cdn.discord.test/voice-no-content-type.ogg',
|
|
548
|
+
},
|
|
549
|
+
],
|
|
550
|
+
})
|
|
551
|
+
|
|
552
|
+
const thread = await discord.channel(TEXT_CHANNEL_ID).waitForThread({
|
|
553
|
+
timeout: 4_000,
|
|
554
|
+
predicate: (t) => {
|
|
555
|
+
return t.name?.includes('Investigate the missing content type path') ?? false
|
|
556
|
+
},
|
|
557
|
+
})
|
|
558
|
+
|
|
559
|
+
const th = discord.thread(thread.id)
|
|
560
|
+
|
|
561
|
+
await waitForBotMessageContaining({
|
|
562
|
+
discord,
|
|
563
|
+
threadId: thread.id,
|
|
564
|
+
userId: TEST_USER_ID,
|
|
565
|
+
text: 'Transcribing voice message',
|
|
566
|
+
timeout: 4_000,
|
|
567
|
+
})
|
|
568
|
+
|
|
569
|
+
await waitForBotMessageContaining({
|
|
570
|
+
discord,
|
|
571
|
+
threadId: thread.id,
|
|
572
|
+
userId: TEST_USER_ID,
|
|
573
|
+
text: 'Investigate the missing content type path',
|
|
574
|
+
timeout: 4_000,
|
|
575
|
+
})
|
|
576
|
+
|
|
577
|
+
await waitForFooterMessage({
|
|
578
|
+
discord,
|
|
579
|
+
threadId: thread.id,
|
|
580
|
+
timeout: 4_000,
|
|
581
|
+
})
|
|
582
|
+
|
|
583
|
+
const finalState = await waitForThreadState({
|
|
584
|
+
threadId: thread.id,
|
|
585
|
+
predicate: (state) => {
|
|
586
|
+
return Boolean(state.sessionId) && state.queueItems.length === 0
|
|
587
|
+
},
|
|
588
|
+
timeout: 4_000,
|
|
589
|
+
description: 'voice attachment without content type settled',
|
|
590
|
+
})
|
|
591
|
+
|
|
592
|
+
expect(await th.text()).toMatchInlineSnapshot(`
|
|
593
|
+
"--- from: user (voice-tester)
|
|
594
|
+
[attachment: voice-message.ogg]
|
|
595
|
+
--- from: assistant (TestBot)
|
|
596
|
+
🎤 Transcribing voice message...
|
|
597
|
+
📝 **Transcribed message:** Investigate the missing content type path
|
|
598
|
+
⬥ session-reply
|
|
599
|
+
*project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
|
|
600
|
+
`)
|
|
601
|
+
|
|
602
|
+
const messages = await waitForSessionMessages({
|
|
603
|
+
projectDirectory: directories.projectDirectory,
|
|
604
|
+
sessionID: finalState.sessionId!,
|
|
605
|
+
timeout: 4_000,
|
|
606
|
+
description: 'voice attachment without content type dispatched once',
|
|
607
|
+
predicate: (all) => {
|
|
608
|
+
const userTexts = getUserTexts(all)
|
|
609
|
+
return userTexts.some((text) => {
|
|
610
|
+
return text.includes('Investigate the missing content type path')
|
|
611
|
+
})
|
|
612
|
+
},
|
|
613
|
+
})
|
|
614
|
+
|
|
615
|
+
const userTexts = getUserTexts(messages)
|
|
616
|
+
expect(userTexts).not.toContain('')
|
|
617
|
+
expect(
|
|
618
|
+
userTexts.some((text) => {
|
|
619
|
+
return text.includes('Investigate the missing content type path')
|
|
620
|
+
}),
|
|
621
|
+
).toBe(true)
|
|
622
|
+
},
|
|
623
|
+
8_000,
|
|
624
|
+
)
|
|
625
|
+
|
|
626
|
+
// ── Test 2: Voice message in thread with idle session ──
|
|
627
|
+
|
|
628
|
+
test(
|
|
629
|
+
'voice message in thread with idle session starts new request',
|
|
630
|
+
async () => {
|
|
631
|
+
// 1. Create a session with a text message first
|
|
632
|
+
setDeterministicTranscription(null) // text message, no transcription
|
|
633
|
+
await discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
|
|
634
|
+
content: 'FAST_RESPONSE_MARKER initial setup',
|
|
635
|
+
})
|
|
636
|
+
|
|
637
|
+
const thread = await discord.channel(TEXT_CHANNEL_ID).waitForThread({
|
|
638
|
+
timeout: 4_000,
|
|
639
|
+
predicate: (t) => {
|
|
640
|
+
return t.name?.includes('FAST_RESPONSE_MARKER') ?? false
|
|
641
|
+
},
|
|
642
|
+
})
|
|
643
|
+
|
|
644
|
+
const th = discord.thread(thread.id)
|
|
645
|
+
|
|
646
|
+
// Wait for the initial setup turn to fully complete before sending voice.
|
|
647
|
+
await waitForBotMessageContaining({
|
|
648
|
+
discord,
|
|
649
|
+
threadId: thread.id,
|
|
650
|
+
userId: TEST_USER_ID,
|
|
651
|
+
text: 'fast-response-done',
|
|
652
|
+
timeout: 4_000,
|
|
653
|
+
})
|
|
654
|
+
await waitForFooterMessage({
|
|
655
|
+
discord,
|
|
656
|
+
threadId: thread.id,
|
|
657
|
+
timeout: 4_000,
|
|
658
|
+
})
|
|
659
|
+
|
|
660
|
+
// 2. Now send a voice message to the idle session
|
|
661
|
+
setDeterministicTranscription({
|
|
662
|
+
transcription: 'Add error handling to the parser',
|
|
663
|
+
queueMessage: false,
|
|
664
|
+
})
|
|
665
|
+
|
|
666
|
+
await th.user(TEST_USER_ID).sendVoiceMessage()
|
|
667
|
+
|
|
668
|
+
// Bot should post transcription messages
|
|
669
|
+
await waitForBotMessageContaining({
|
|
670
|
+
discord,
|
|
671
|
+
threadId: thread.id,
|
|
672
|
+
userId: TEST_USER_ID,
|
|
673
|
+
text: 'Transcribing voice message',
|
|
674
|
+
timeout: 4_000,
|
|
675
|
+
})
|
|
676
|
+
|
|
677
|
+
await waitForBotMessageContaining({
|
|
678
|
+
discord,
|
|
679
|
+
threadId: thread.id,
|
|
680
|
+
userId: TEST_USER_ID,
|
|
681
|
+
text: 'Add error handling to the parser',
|
|
682
|
+
timeout: 4_000,
|
|
683
|
+
})
|
|
684
|
+
|
|
685
|
+
await waitForBotMessageContaining({
|
|
686
|
+
discord,
|
|
687
|
+
threadId: thread.id,
|
|
688
|
+
userId: TEST_USER_ID,
|
|
689
|
+
text: 'session-reply',
|
|
690
|
+
timeout: 4_000,
|
|
691
|
+
})
|
|
692
|
+
|
|
693
|
+
await waitForFooterMessage({
|
|
694
|
+
discord,
|
|
695
|
+
threadId: thread.id,
|
|
696
|
+
timeout: 4_000,
|
|
697
|
+
afterMessageIncludes: 'session-reply',
|
|
698
|
+
afterAuthorId: discord.botUserId,
|
|
699
|
+
})
|
|
700
|
+
|
|
701
|
+
const finalState = getThreadState(thread.id)
|
|
702
|
+
expect(await th.text()).toMatchInlineSnapshot(`
|
|
703
|
+
"--- from: user (voice-tester)
|
|
704
|
+
FAST_RESPONSE_MARKER initial setup
|
|
705
|
+
--- from: assistant (TestBot)
|
|
706
|
+
⬥ fast-response-done
|
|
707
|
+
*project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
|
|
708
|
+
--- from: user (voice-tester)
|
|
709
|
+
[attachment: voice-message.ogg]
|
|
710
|
+
--- from: assistant (TestBot)
|
|
711
|
+
🎤 Transcribing voice message...
|
|
712
|
+
📝 **Transcribed message:** Add error handling to the parser
|
|
713
|
+
⬥ session-reply
|
|
714
|
+
*project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
|
|
715
|
+
`)
|
|
716
|
+
expect(finalState?.sessionId).toBeDefined()
|
|
717
|
+
if (!finalState?.sessionId) {
|
|
718
|
+
throw new Error('Expected final state with sessionId')
|
|
719
|
+
}
|
|
720
|
+
expect(finalState.queueItems.length).toBe(0)
|
|
721
|
+
|
|
722
|
+
// Verify the same OpenCode session received both prompts:
|
|
723
|
+
// the initial text message AND the voice transcription
|
|
724
|
+
const messages = await waitForSessionMessages({
|
|
725
|
+
projectDirectory: directories.projectDirectory,
|
|
726
|
+
sessionID: finalState.sessionId,
|
|
727
|
+
timeout: 4_000,
|
|
728
|
+
description: 'idle session receives voice transcription prompt',
|
|
729
|
+
predicate: (all) => {
|
|
730
|
+
const userTexts = getUserTexts(all)
|
|
731
|
+
return (
|
|
732
|
+
userTexts.some((t) => t.includes('FAST_RESPONSE_MARKER initial setup')) &&
|
|
733
|
+
userTexts.some((t) => t.includes('Add error handling to the parser'))
|
|
734
|
+
)
|
|
735
|
+
},
|
|
736
|
+
})
|
|
737
|
+
const userTexts = getUserTexts(messages)
|
|
738
|
+
expect(userTexts.some((t) => t.includes('FAST_RESPONSE_MARKER initial setup'))).toBe(true)
|
|
739
|
+
expect(userTexts.some((t) => t.includes('Add error handling to the parser'))).toBe(true)
|
|
740
|
+
// Both prompts should have gotten assistant responses
|
|
741
|
+
const assistantTexts = getAssistantTexts(messages)
|
|
742
|
+
expect(assistantTexts.length).toBeGreaterThanOrEqual(2)
|
|
743
|
+
},
|
|
744
|
+
8_000,
|
|
745
|
+
)
|
|
746
|
+
|
|
747
|
+
// ── Test 3: Voice message queues behind running session (default) ──
|
|
748
|
+
|
|
749
|
+
test.skip(
|
|
750
|
+
'voice message with queueMessage=false queues behind running session',
|
|
751
|
+
async () => {
|
|
752
|
+
// 1. Start a session with a slow response
|
|
753
|
+
setDeterministicTranscription(null)
|
|
754
|
+
await discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
|
|
755
|
+
content: 'SLOW_RESPONSE_MARKER start slow task',
|
|
756
|
+
})
|
|
757
|
+
|
|
758
|
+
const thread = await discord.channel(TEXT_CHANNEL_ID).waitForThread({
|
|
759
|
+
timeout: 4_000,
|
|
760
|
+
predicate: (t) => {
|
|
761
|
+
return t.name?.includes('SLOW_RESPONSE_MARKER') ?? false
|
|
762
|
+
},
|
|
763
|
+
})
|
|
764
|
+
|
|
765
|
+
const th = discord.thread(thread.id)
|
|
766
|
+
|
|
767
|
+
// 2. Send voice message while session is running (default: queue)
|
|
768
|
+
setDeterministicTranscription({
|
|
769
|
+
transcription: 'Stop and do this instead',
|
|
770
|
+
queueMessage: false,
|
|
771
|
+
})
|
|
772
|
+
|
|
773
|
+
await th.user(TEST_USER_ID).sendVoiceMessage()
|
|
774
|
+
|
|
775
|
+
// 3. Wait for transcription to appear first
|
|
776
|
+
await waitForBotMessageContaining({
|
|
777
|
+
discord,
|
|
778
|
+
threadId: thread.id,
|
|
779
|
+
userId: TEST_USER_ID,
|
|
780
|
+
text: 'Stop and do this instead',
|
|
781
|
+
timeout: 4_000,
|
|
782
|
+
})
|
|
783
|
+
|
|
784
|
+
// queueMessage=false no longer interrupts by default, so we should NOT
|
|
785
|
+
// receive the queued-position ack that queueMessage=true sends.
|
|
786
|
+
const afterTranscription = await th.getMessages()
|
|
787
|
+
const hasQueuedAck = afterTranscription.some((m) => {
|
|
788
|
+
return (
|
|
789
|
+
m.author.id === discord.botUserId &&
|
|
790
|
+
m.content.includes('Queued at position')
|
|
791
|
+
)
|
|
792
|
+
})
|
|
793
|
+
expect(hasQueuedAck).toBe(false)
|
|
794
|
+
|
|
795
|
+
const midState = getThreadState(thread.id)
|
|
796
|
+
expect(midState).toBeDefined()
|
|
797
|
+
|
|
798
|
+
// 4. Wait for both runs to finish (slow prompt + queued transcription)
|
|
799
|
+
const finalState = await waitForThreadState({
|
|
800
|
+
threadId: thread.id,
|
|
801
|
+
predicate: (s) => {
|
|
802
|
+
return s.queueItems.length === 0
|
|
803
|
+
},
|
|
804
|
+
timeout: 8_000,
|
|
805
|
+
description: 'queue empty (default queued voice behavior)',
|
|
806
|
+
})
|
|
807
|
+
expect(finalState.sessionId).toBeDefined()
|
|
808
|
+
expect(finalState.queueItems.length).toBe(0)
|
|
809
|
+
|
|
810
|
+
// Verify the OpenCode session processed both prompts sequentially.
|
|
811
|
+
const messages = await waitForSessionMessages({
|
|
812
|
+
projectDirectory: directories.projectDirectory,
|
|
813
|
+
sessionID: finalState.sessionId!,
|
|
814
|
+
timeout: 4_000,
|
|
815
|
+
description: 'default queue: original prompt + voice prompt',
|
|
816
|
+
predicate: (all) => {
|
|
817
|
+
const userTexts = getUserTexts(all)
|
|
818
|
+
const assistantTexts = getAssistantTexts(all)
|
|
819
|
+
return (
|
|
820
|
+
userTexts.some((t) => t.includes('SLOW_RESPONSE_MARKER start slow task')) &&
|
|
821
|
+
userTexts.some((t) => t.includes('Stop and do this instead')) &&
|
|
822
|
+
assistantTexts.some((t) => t.includes('slow-response-done')) &&
|
|
823
|
+
assistantTexts.some((t) => t.includes('session-reply'))
|
|
824
|
+
)
|
|
825
|
+
},
|
|
826
|
+
})
|
|
827
|
+
const userTexts = getUserTexts(messages)
|
|
828
|
+
// Both prompts were sent to the same session
|
|
829
|
+
expect(userTexts.some((t) => t.includes('SLOW_RESPONSE_MARKER start slow task'))).toBe(true)
|
|
830
|
+
expect(userTexts.some((t) => t.includes('Stop and do this instead'))).toBe(true)
|
|
831
|
+
|
|
832
|
+
const assistantTexts = getAssistantTexts(messages)
|
|
833
|
+
expect(assistantTexts.some((t) => t.includes('slow-response-done'))).toBe(true)
|
|
834
|
+
expect(assistantTexts.some((t) => t.includes('session-reply'))).toBe(true)
|
|
835
|
+
},
|
|
836
|
+
10_000,
|
|
837
|
+
)
|
|
838
|
+
|
|
839
|
+
// ── Test 4: Voice message with queueMessage=true queues instead of interrupting ──
|
|
840
|
+
|
|
841
|
+
test(
|
|
842
|
+
'voice message with queueMessage=true queues behind running session',
|
|
843
|
+
async () => {
|
|
844
|
+
// 1. Start a session with a slow response
|
|
845
|
+
setDeterministicTranscription(null)
|
|
846
|
+
await discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
|
|
847
|
+
content: 'SLOW_RESPONSE_MARKER start queued task',
|
|
848
|
+
})
|
|
849
|
+
|
|
850
|
+
const thread = await discord.channel(TEXT_CHANNEL_ID).waitForThread({
|
|
851
|
+
timeout: 4_000,
|
|
852
|
+
predicate: (t) => {
|
|
853
|
+
return t.name?.includes('start queued task') ?? false
|
|
854
|
+
},
|
|
855
|
+
})
|
|
856
|
+
|
|
857
|
+
const th = discord.thread(thread.id)
|
|
858
|
+
|
|
859
|
+
// 2. Send voice message with queueMessage=true (should NOT interrupt)
|
|
860
|
+
setDeterministicTranscription({
|
|
861
|
+
transcription: 'Queue this task for later',
|
|
862
|
+
queueMessage: true,
|
|
863
|
+
})
|
|
864
|
+
|
|
865
|
+
await th.user(TEST_USER_ID).sendVoiceMessage()
|
|
866
|
+
|
|
867
|
+
// 3. Transcription should appear, followed by queue notification
|
|
868
|
+
await waitForBotMessageContaining({
|
|
869
|
+
discord,
|
|
870
|
+
threadId: thread.id,
|
|
871
|
+
userId: TEST_USER_ID,
|
|
872
|
+
text: 'Queue this task for later',
|
|
873
|
+
timeout: 4_000,
|
|
874
|
+
})
|
|
875
|
+
|
|
876
|
+
const messagesWithQueueAck = await waitForBotMessageContaining({
|
|
877
|
+
discord,
|
|
878
|
+
threadId: thread.id,
|
|
879
|
+
userId: TEST_USER_ID,
|
|
880
|
+
text: 'Queued at position',
|
|
881
|
+
timeout: 4_000,
|
|
882
|
+
})
|
|
883
|
+
const queueAckMessage = messagesWithQueueAck.find((message) => {
|
|
884
|
+
return (
|
|
885
|
+
message.author.id === discord.botUserId
|
|
886
|
+
&& message.content.includes('Queued at position')
|
|
887
|
+
)
|
|
888
|
+
})
|
|
889
|
+
expect(queueAckMessage).toBeDefined()
|
|
890
|
+
|
|
891
|
+
// 4. queueMessage=true should not interrupt the in-flight response.
|
|
892
|
+
await waitForBotMessageContaining({
|
|
893
|
+
discord,
|
|
894
|
+
threadId: thread.id,
|
|
895
|
+
userId: TEST_USER_ID,
|
|
896
|
+
text: 'slow-response-done',
|
|
897
|
+
timeout: 4_000,
|
|
898
|
+
})
|
|
899
|
+
|
|
900
|
+
if (!queueAckMessage) {
|
|
901
|
+
throw new Error('Expected queue ack message')
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
const dispatchPrefix = '» **voice-tester:** Voice message transcription from Discord user:'
|
|
905
|
+
const messagesWithDispatch = await waitForBotMessageContaining({
|
|
906
|
+
discord,
|
|
907
|
+
threadId: thread.id,
|
|
908
|
+
text: dispatchPrefix,
|
|
909
|
+
afterMessageId: queueAckMessage.id,
|
|
910
|
+
timeout: 8_000,
|
|
911
|
+
})
|
|
912
|
+
const dispatchMessage = messagesWithDispatch.find((message) => {
|
|
913
|
+
return (
|
|
914
|
+
message.author.id === discord.botUserId
|
|
915
|
+
&& message.content.includes(dispatchPrefix)
|
|
916
|
+
)
|
|
917
|
+
})
|
|
918
|
+
expect(dispatchMessage).toBeDefined()
|
|
919
|
+
|
|
920
|
+
if (!dispatchMessage) {
|
|
921
|
+
throw new Error('Expected queued dispatch indicator message')
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
await waitForBotMessageContaining({
|
|
925
|
+
discord,
|
|
926
|
+
threadId: thread.id,
|
|
927
|
+
text: 'session-reply',
|
|
928
|
+
afterMessageId: dispatchMessage.id,
|
|
929
|
+
timeout: 8_000,
|
|
930
|
+
})
|
|
931
|
+
|
|
932
|
+
// 5. Wait for the slow session to finish AND the queue to drain.
|
|
933
|
+
// Using waitForThreadState with a compound predicate avoids matching
|
|
934
|
+
// the transient 'idle' state from run A before run B starts.
|
|
935
|
+
const finalState = await waitForThreadState({
|
|
936
|
+
threadId: thread.id,
|
|
937
|
+
predicate: (s) => {
|
|
938
|
+
return s.queueItems.length === 0
|
|
939
|
+
},
|
|
940
|
+
timeout: 8_000,
|
|
941
|
+
description: 'queue empty (both runs completed)',
|
|
942
|
+
})
|
|
943
|
+
|
|
944
|
+
await waitForFooterMessage({
|
|
945
|
+
discord,
|
|
946
|
+
threadId: thread.id,
|
|
947
|
+
timeout: 4_000,
|
|
948
|
+
afterMessageIncludes: 'session-reply',
|
|
949
|
+
afterAuthorId: discord.botUserId,
|
|
950
|
+
})
|
|
951
|
+
|
|
952
|
+
expect(await th.text()).toMatchInlineSnapshot(`
|
|
953
|
+
"--- from: user (voice-tester)
|
|
954
|
+
SLOW_RESPONSE_MARKER start queued task
|
|
955
|
+
[attachment: voice-message.ogg]
|
|
956
|
+
--- from: assistant (TestBot)
|
|
957
|
+
🎤 Transcribing voice message...
|
|
958
|
+
📝 **Transcribed message:** Queue this task for later
|
|
959
|
+
Queued at position 1
|
|
960
|
+
⬥ slow-response-done
|
|
961
|
+
*project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
|
|
962
|
+
» **voice-tester:** Voice message transcription from Discord user:
|
|
963
|
+
Queue this task for later
|
|
964
|
+
⬥ session-reply
|
|
965
|
+
*project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
|
|
966
|
+
`)
|
|
967
|
+
expect(finalState.queueItems.length).toBe(0)
|
|
968
|
+
|
|
969
|
+
// Verify the OpenCode session processed BOTH prompts sequentially:
|
|
970
|
+
// the slow initial prompt completed, then the queued voice prompt ran
|
|
971
|
+
const messages = await waitForSessionMessages({
|
|
972
|
+
projectDirectory: directories.projectDirectory,
|
|
973
|
+
sessionID: finalState.sessionId!,
|
|
974
|
+
timeout: 4_000,
|
|
975
|
+
description: 'queue: both prompts processed with responses',
|
|
976
|
+
predicate: (all) => {
|
|
977
|
+
const userTexts = getUserTexts(all)
|
|
978
|
+
const assistantTexts = getAssistantTexts(all)
|
|
979
|
+
return (
|
|
980
|
+
userTexts.some((t) => t.includes('SLOW_RESPONSE_MARKER start queued task')) &&
|
|
981
|
+
userTexts.some((t) => t.includes('Queue this task for later')) &&
|
|
982
|
+
assistantTexts.some((t) => t.includes('slow-response-done')) &&
|
|
983
|
+
assistantTexts.some((t) => t.includes('session-reply'))
|
|
984
|
+
)
|
|
985
|
+
},
|
|
986
|
+
})
|
|
987
|
+
const userTexts = getUserTexts(messages)
|
|
988
|
+
const assistantTexts = getAssistantTexts(messages)
|
|
989
|
+
// Both prompts sent to the session
|
|
990
|
+
expect(userTexts.some((t) => t.includes('SLOW_RESPONSE_MARKER start queued task'))).toBe(true)
|
|
991
|
+
expect(userTexts.some((t) => t.includes('Queue this task for later'))).toBe(true)
|
|
992
|
+
// Both got responses (slow response + default reply for queued message)
|
|
993
|
+
expect(assistantTexts.some((t) => t.includes('slow-response-done'))).toBe(true)
|
|
994
|
+
expect(assistantTexts.some((t) => t.includes('session-reply'))).toBe(true)
|
|
995
|
+
// No abort errors — the queue preserved the first run
|
|
996
|
+
const abortedAssistant = messages.find((m) => {
|
|
997
|
+
return m.info.role === 'assistant' && m.info.error?.name === 'MessageAbortedError'
|
|
998
|
+
})
|
|
999
|
+
expect(abortedAssistant).toBeUndefined()
|
|
1000
|
+
},
|
|
1001
|
+
12_000,
|
|
1002
|
+
)
|
|
1003
|
+
|
|
1004
|
+
// ── Test 5: Slow transcription finishes after session becomes idle (race condition) ──
|
|
1005
|
+
|
|
1006
|
+
test(
|
|
1007
|
+
'slow transcription completing after session finishes is handled correctly',
|
|
1008
|
+
async () => {
|
|
1009
|
+
// This tests the race condition where:
|
|
1010
|
+
// 1. Session starts with a fast response (~100ms)
|
|
1011
|
+
// 2. Voice message is sent simultaneously with slow transcription (500ms)
|
|
1012
|
+
// 3. The fast session finishes BEFORE transcription completes
|
|
1013
|
+
// 4. When transcription completes, the session is idle → should start new request
|
|
1014
|
+
|
|
1015
|
+
// 1. Start a session with a fast response
|
|
1016
|
+
setDeterministicTranscription(null)
|
|
1017
|
+
await discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
|
|
1018
|
+
content: 'FAST_RESPONSE_MARKER quick task',
|
|
1019
|
+
})
|
|
1020
|
+
|
|
1021
|
+
const thread = await discord.channel(TEXT_CHANNEL_ID).waitForThread({
|
|
1022
|
+
timeout: 4_000,
|
|
1023
|
+
predicate: (t) => {
|
|
1024
|
+
return t.name?.includes('quick task') ?? false
|
|
1025
|
+
},
|
|
1026
|
+
})
|
|
1027
|
+
|
|
1028
|
+
const th = discord.thread(thread.id)
|
|
1029
|
+
|
|
1030
|
+
// Wait for the first run to complete before sending voice.
|
|
1031
|
+
await th.waitForBotReply({ timeout: 4_000 })
|
|
1032
|
+
await waitForFooterMessage({
|
|
1033
|
+
discord,
|
|
1034
|
+
threadId: thread.id,
|
|
1035
|
+
timeout: 4_000,
|
|
1036
|
+
})
|
|
1037
|
+
|
|
1038
|
+
// 2. Now send voice message with slow transcription
|
|
1039
|
+
// The fast response completes in ~100ms, but transcription takes 500ms.
|
|
1040
|
+
// By the time transcription returns, the session is already idle.
|
|
1041
|
+
setDeterministicTranscription({
|
|
1042
|
+
transcription: 'Delayed transcription result',
|
|
1043
|
+
queueMessage: false,
|
|
1044
|
+
delayMs: 500,
|
|
1045
|
+
})
|
|
1046
|
+
|
|
1047
|
+
await th.user(TEST_USER_ID).sendVoiceMessage()
|
|
1048
|
+
|
|
1049
|
+
// 3. The transcription should complete after the session finishes
|
|
1050
|
+
// and the transcribed message should be processed as a new request
|
|
1051
|
+
await waitForBotMessageContaining({
|
|
1052
|
+
discord,
|
|
1053
|
+
threadId: thread.id,
|
|
1054
|
+
userId: TEST_USER_ID,
|
|
1055
|
+
text: 'Delayed transcription result',
|
|
1056
|
+
timeout: 4_000,
|
|
1057
|
+
})
|
|
1058
|
+
|
|
1059
|
+
await waitForFooterMessage({
|
|
1060
|
+
discord,
|
|
1061
|
+
threadId: thread.id,
|
|
1062
|
+
timeout: 4_000,
|
|
1063
|
+
afterMessageIncludes: 'Delayed transcription result',
|
|
1064
|
+
afterAuthorId: discord.botUserId,
|
|
1065
|
+
})
|
|
1066
|
+
|
|
1067
|
+
// 4. Session should process the delayed transcription and settle.
|
|
1068
|
+
const finalState = await waitForThreadState({
|
|
1069
|
+
threadId: thread.id,
|
|
1070
|
+
predicate: (state) => {
|
|
1071
|
+
return Boolean(state.sessionId) && state.queueItems.length === 0
|
|
1072
|
+
},
|
|
1073
|
+
timeout: 4_000,
|
|
1074
|
+
description: 'delayed transcription settled with empty queue',
|
|
1075
|
+
})
|
|
1076
|
+
|
|
1077
|
+
expect(await th.text()).toMatchInlineSnapshot(`
|
|
1078
|
+
"--- from: user (voice-tester)
|
|
1079
|
+
FAST_RESPONSE_MARKER quick task
|
|
1080
|
+
--- from: assistant (TestBot)
|
|
1081
|
+
⬥ fast-response-done
|
|
1082
|
+
*project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
|
|
1083
|
+
--- from: user (voice-tester)
|
|
1084
|
+
[attachment: voice-message.ogg]
|
|
1085
|
+
--- from: assistant (TestBot)
|
|
1086
|
+
🎤 Transcribing voice message...
|
|
1087
|
+
📝 **Transcribed message:** Delayed transcription result
|
|
1088
|
+
⬥ session-reply
|
|
1089
|
+
*project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
|
|
1090
|
+
`)
|
|
1091
|
+
expect(finalState.sessionId).toBeDefined()
|
|
1092
|
+
expect(finalState.queueItems.length).toBe(0)
|
|
1093
|
+
|
|
1094
|
+
// 5. Verify the OpenCode session processed both prompts on the same session:
|
|
1095
|
+
// the fast text message completed first, then the delayed voice transcription
|
|
1096
|
+
const sessionMessages = await waitForSessionMessages({
|
|
1097
|
+
projectDirectory: directories.projectDirectory,
|
|
1098
|
+
sessionID: finalState.sessionId!,
|
|
1099
|
+
timeout: 4_000,
|
|
1100
|
+
description: 'race: both prompts processed with responses on same session',
|
|
1101
|
+
predicate: (all) => {
|
|
1102
|
+
const userTexts = getUserTexts(all)
|
|
1103
|
+
const aTexts = getAssistantTexts(all)
|
|
1104
|
+
return (
|
|
1105
|
+
userTexts.some((t) => t.includes('FAST_RESPONSE_MARKER quick task')) &&
|
|
1106
|
+
userTexts.some((t) => t.includes('Delayed transcription result')) &&
|
|
1107
|
+
aTexts.length >= 2
|
|
1108
|
+
)
|
|
1109
|
+
},
|
|
1110
|
+
})
|
|
1111
|
+
const userTexts = getUserTexts(sessionMessages)
|
|
1112
|
+
expect(userTexts.some((t) => t.includes('FAST_RESPONSE_MARKER quick task'))).toBe(true)
|
|
1113
|
+
expect(userTexts.some((t) => t.includes('Delayed transcription result'))).toBe(true)
|
|
1114
|
+
// Both prompts got assistant responses (no aborts — second arrived after first finished)
|
|
1115
|
+
const assistantTexts = getAssistantTexts(sessionMessages)
|
|
1116
|
+
expect(assistantTexts.length).toBeGreaterThanOrEqual(2)
|
|
1117
|
+
const abortedAssistant = sessionMessages.find((m) => {
|
|
1118
|
+
return m.info.role === 'assistant' && m.info.error?.name === 'MessageAbortedError'
|
|
1119
|
+
})
|
|
1120
|
+
expect(abortedAssistant).toBeUndefined()
|
|
1121
|
+
},
|
|
1122
|
+
8_000,
|
|
1123
|
+
)
|
|
1124
|
+
|
|
1125
|
+
// ── Test 6: Slow transcription with queueMessage=true arriving after idle queue drain ──
|
|
1126
|
+
|
|
1127
|
+
test(
|
|
1128
|
+
'slow queued transcription completing after session idle is dispatched',
|
|
1129
|
+
async () => {
|
|
1130
|
+
// Reproduces the bug where a voice message with queueMessage=true has
|
|
1131
|
+
// slow transcription that completes after the session is already idle.
|
|
1132
|
+
// The message gets inserted into the local queue but was never drained
|
|
1133
|
+
// because handleSessionIdle() didn't call tryDrainQueue().
|
|
1134
|
+
//
|
|
1135
|
+
// 1. Send a fast text message → session starts and finishes quickly
|
|
1136
|
+
// 2. Send a voice message with queueMessage=true and slow transcription
|
|
1137
|
+
// 3. Fast session finishes → handleSessionIdle fires
|
|
1138
|
+
// 4. Transcription completes → enqueueViaLocalQueue adds item
|
|
1139
|
+
// 5. tryDrainQueue in handleSessionIdle should pick it up
|
|
1140
|
+
|
|
1141
|
+
// 1. Start a session with a fast response
|
|
1142
|
+
setDeterministicTranscription(null)
|
|
1143
|
+
await discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
|
|
1144
|
+
content: 'FAST_RESPONSE_MARKER fast before queued voice',
|
|
1145
|
+
})
|
|
1146
|
+
|
|
1147
|
+
const thread = await discord.channel(TEXT_CHANNEL_ID).waitForThread({
|
|
1148
|
+
timeout: 4_000,
|
|
1149
|
+
predicate: (t) => {
|
|
1150
|
+
return t.name?.includes('fast before queued voice') ?? false
|
|
1151
|
+
},
|
|
1152
|
+
})
|
|
1153
|
+
|
|
1154
|
+
const th = discord.thread(thread.id)
|
|
1155
|
+
|
|
1156
|
+
// Wait for the first run to fully complete before sending the queued voice message.
|
|
1157
|
+
await th.waitForBotReply({ timeout: 4_000 })
|
|
1158
|
+
await waitForFooterMessage({
|
|
1159
|
+
discord,
|
|
1160
|
+
threadId: thread.id,
|
|
1161
|
+
timeout: 4_000,
|
|
1162
|
+
})
|
|
1163
|
+
|
|
1164
|
+
// 2. Send voice message with queueMessage=true AND slow transcription.
|
|
1165
|
+
// Session is already idle when this arrives. The transcription delay
|
|
1166
|
+
// means the message enters the local queue after idle has fired.
|
|
1167
|
+
setDeterministicTranscription({
|
|
1168
|
+
transcription: 'Queued voice after idle',
|
|
1169
|
+
queueMessage: true,
|
|
1170
|
+
delayMs: 500,
|
|
1171
|
+
})
|
|
1172
|
+
|
|
1173
|
+
await th.user(TEST_USER_ID).sendVoiceMessage()
|
|
1174
|
+
|
|
1175
|
+
// 3. The transcription should complete, and even though queueMessage=true
|
|
1176
|
+
// routes through the local queue, the item should be drained immediately
|
|
1177
|
+
// because the session is idle. No dispatch indicator (» prefix) appears
|
|
1178
|
+
// because the message is dispatched immediately by enqueueViaLocalQueue's
|
|
1179
|
+
// tryDrainQueue (showIndicator=false for first drain).
|
|
1180
|
+
await waitForBotMessageContaining({
|
|
1181
|
+
discord,
|
|
1182
|
+
threadId: thread.id,
|
|
1183
|
+
userId: TEST_USER_ID,
|
|
1184
|
+
text: 'Queued voice after idle',
|
|
1185
|
+
timeout: 4_000,
|
|
1186
|
+
})
|
|
1187
|
+
|
|
1188
|
+
// Wait for the queued message response and footer
|
|
1189
|
+
await waitForBotMessageContaining({
|
|
1190
|
+
discord,
|
|
1191
|
+
threadId: thread.id,
|
|
1192
|
+
text: 'session-reply',
|
|
1193
|
+
timeout: 4_000,
|
|
1194
|
+
})
|
|
1195
|
+
|
|
1196
|
+
await waitForFooterMessage({
|
|
1197
|
+
discord,
|
|
1198
|
+
threadId: thread.id,
|
|
1199
|
+
timeout: 4_000,
|
|
1200
|
+
afterMessageIncludes: 'session-reply',
|
|
1201
|
+
afterAuthorId: discord.botUserId,
|
|
1202
|
+
})
|
|
1203
|
+
|
|
1204
|
+
// 4. Final state: queue should be empty
|
|
1205
|
+
const finalState = await waitForThreadState({
|
|
1206
|
+
threadId: thread.id,
|
|
1207
|
+
predicate: (state) => {
|
|
1208
|
+
return Boolean(state.sessionId) && state.queueItems.length === 0
|
|
1209
|
+
},
|
|
1210
|
+
timeout: 4_000,
|
|
1211
|
+
description: 'queued voice after idle settled with empty queue',
|
|
1212
|
+
})
|
|
1213
|
+
|
|
1214
|
+
expect(await th.text()).toMatchInlineSnapshot(`
|
|
1215
|
+
"--- from: user (voice-tester)
|
|
1216
|
+
FAST_RESPONSE_MARKER fast before queued voice
|
|
1217
|
+
--- from: assistant (TestBot)
|
|
1218
|
+
⬥ fast-response-done
|
|
1219
|
+
*project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
|
|
1220
|
+
--- from: user (voice-tester)
|
|
1221
|
+
[attachment: voice-message.ogg]
|
|
1222
|
+
--- from: assistant (TestBot)
|
|
1223
|
+
🎤 Transcribing voice message...
|
|
1224
|
+
📝 **Transcribed message:** Queued voice after idle
|
|
1225
|
+
⬥ session-reply
|
|
1226
|
+
*project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
|
|
1227
|
+
`)
|
|
1228
|
+
expect(finalState.sessionId).toBeDefined()
|
|
1229
|
+
expect(finalState.queueItems.length).toBe(0)
|
|
1230
|
+
|
|
1231
|
+
// 5. Verify the OpenCode session processed both prompts
|
|
1232
|
+
const sessionMessages = await waitForSessionMessages({
|
|
1233
|
+
projectDirectory: directories.projectDirectory,
|
|
1234
|
+
sessionID: finalState.sessionId!,
|
|
1235
|
+
timeout: 4_000,
|
|
1236
|
+
description: 'queued-voice-idle: both prompts processed',
|
|
1237
|
+
predicate: (all) => {
|
|
1238
|
+
const userTexts = getUserTexts(all)
|
|
1239
|
+
const aTexts = getAssistantTexts(all)
|
|
1240
|
+
return (
|
|
1241
|
+
userTexts.some((t) => t.includes('FAST_RESPONSE_MARKER fast before queued voice')) &&
|
|
1242
|
+
userTexts.some((t) => t.includes('Queued voice after idle')) &&
|
|
1243
|
+
aTexts.length >= 2
|
|
1244
|
+
)
|
|
1245
|
+
},
|
|
1246
|
+
})
|
|
1247
|
+
const userTexts = getUserTexts(sessionMessages)
|
|
1248
|
+
expect(userTexts.some((t) => t.includes('FAST_RESPONSE_MARKER fast before queued voice'))).toBe(true)
|
|
1249
|
+
expect(userTexts.some((t) => t.includes('Queued voice after idle'))).toBe(true)
|
|
1250
|
+
const assistantTexts = getAssistantTexts(sessionMessages)
|
|
1251
|
+
expect(assistantTexts.length).toBeGreaterThanOrEqual(2)
|
|
1252
|
+
},
|
|
1253
|
+
10_000,
|
|
1254
|
+
)
|
|
1255
|
+
})
|