discoclaw 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.context/README.md +42 -0
- package/.context/architecture.md +58 -0
- package/.context/bot-setup.md +24 -0
- package/.context/dev.md +230 -0
- package/.context/discord.md +144 -0
- package/.context/memory.md +257 -0
- package/.context/ops.md +59 -0
- package/.context/pa-safety.md +47 -0
- package/.context/pa.md +118 -0
- package/.context/project.md +43 -0
- package/.context/runtime.md +253 -0
- package/.context/tasks.md +71 -0
- package/.context/tools.md +75 -0
- package/.env.example +88 -0
- package/.env.example.full +378 -0
- package/LICENSE +21 -0
- package/README.md +220 -0
- package/dist/beads/auto-tag.js +2 -0
- package/dist/beads/auto-tag.test.js +62 -0
- package/dist/beads/bd-cli.js +9 -0
- package/dist/beads/bd-cli.test.js +495 -0
- package/dist/beads/bead-hooks-cli.js +149 -0
- package/dist/beads/bead-sync-cli.js +5 -0
- package/dist/beads/bead-sync-cli.test.js +72 -0
- package/dist/beads/bead-sync-coordinator.js +4 -0
- package/dist/beads/bead-sync-coordinator.test.js +239 -0
- package/dist/beads/bead-sync-watcher.js +2 -0
- package/dist/beads/bead-sync-watcher.test.js +96 -0
- package/dist/beads/bead-sync.js +7 -0
- package/dist/beads/bead-sync.test.js +876 -0
- package/dist/beads/bead-thread-cache.js +8 -0
- package/dist/beads/bead-thread-cache.test.js +91 -0
- package/dist/beads/discord-sync.js +18 -0
- package/dist/beads/discord-sync.test.js +782 -0
- package/dist/beads/find-bead-by-thread.test.js +36 -0
- package/dist/beads/forum-guard.js +2 -0
- package/dist/beads/forum-guard.test.js +204 -0
- package/dist/beads/initialize.js +3 -0
- package/dist/beads/initialize.test.js +304 -0
- package/dist/beads/types.js +10 -0
- package/dist/cli/daemon-installer.js +225 -0
- package/dist/cli/daemon-installer.test.js +289 -0
- package/dist/cli/index.js +42 -0
- package/dist/cli/init-wizard.js +374 -0
- package/dist/cli/init-wizard.test.js +191 -0
- package/dist/config.js +385 -0
- package/dist/config.test.js +589 -0
- package/dist/cron/auto-tag.js +100 -0
- package/dist/cron/auto-tag.test.js +91 -0
- package/dist/cron/cadence.js +74 -0
- package/dist/cron/cadence.test.js +53 -0
- package/dist/cron/cron-sync-coordinator.js +66 -0
- package/dist/cron/cron-sync-coordinator.test.js +118 -0
- package/dist/cron/cron-sync.js +165 -0
- package/dist/cron/cron-sync.test.js +228 -0
- package/dist/cron/cron-tag-map-watcher.js +128 -0
- package/dist/cron/cron-tag-map-watcher.test.js +155 -0
- package/dist/cron/default-timezone.js +23 -0
- package/dist/cron/default-timezone.test.js +30 -0
- package/dist/cron/discord-sync.js +205 -0
- package/dist/cron/discord-sync.test.js +353 -0
- package/dist/cron/executor.js +303 -0
- package/dist/cron/executor.test.js +614 -0
- package/dist/cron/forum-sync.js +347 -0
- package/dist/cron/forum-sync.test.js +539 -0
- package/dist/cron/job-lock.js +164 -0
- package/dist/cron/job-lock.test.js +178 -0
- package/dist/cron/parser.js +68 -0
- package/dist/cron/parser.test.js +115 -0
- package/dist/cron/run-control.js +24 -0
- package/dist/cron/run-control.test.js +27 -0
- package/dist/cron/run-stats.js +265 -0
- package/dist/cron/run-stats.test.js +160 -0
- package/dist/cron/scheduler.js +97 -0
- package/dist/cron/scheduler.test.js +112 -0
- package/dist/cron/tag-map.js +47 -0
- package/dist/cron/tag-map.test.js +64 -0
- package/dist/cron/types.js +1 -0
- package/dist/discoclaw-plan-format.test.js +137 -0
- package/dist/discoclaw-recipe-format.test.js +137 -0
- package/dist/discord/abort-registry.js +70 -0
- package/dist/discord/action-categories.js +36 -0
- package/dist/discord/action-types.js +1 -0
- package/dist/discord/action-utils.js +58 -0
- package/dist/discord/action-utils.test.js +58 -0
- package/dist/discord/actions-beads.js +1 -0
- package/dist/discord/actions-beads.test.js +372 -0
- package/dist/discord/actions-bot-profile.js +107 -0
- package/dist/discord/actions-bot-profile.test.js +138 -0
- package/dist/discord/actions-channels.js +427 -0
- package/dist/discord/actions-channels.test.js +697 -0
- package/dist/discord/actions-config.js +173 -0
- package/dist/discord/actions-config.test.js +322 -0
- package/dist/discord/actions-crons.js +586 -0
- package/dist/discord/actions-crons.test.js +499 -0
- package/dist/discord/actions-defer.js +60 -0
- package/dist/discord/actions-defer.test.js +134 -0
- package/dist/discord/actions-forge.js +134 -0
- package/dist/discord/actions-forge.test.js +206 -0
- package/dist/discord/actions-guild.js +301 -0
- package/dist/discord/actions-guild.test.js +386 -0
- package/dist/discord/actions-memory.js +106 -0
- package/dist/discord/actions-memory.test.js +248 -0
- package/dist/discord/actions-messaging.js +401 -0
- package/dist/discord/actions-messaging.test.js +738 -0
- package/dist/discord/actions-moderation.js +65 -0
- package/dist/discord/actions-moderation.test.js +88 -0
- package/dist/discord/actions-plan.js +445 -0
- package/dist/discord/actions-plan.test.js +610 -0
- package/dist/discord/actions-poll.js +38 -0
- package/dist/discord/actions-poll.test.js +93 -0
- package/dist/discord/actions-tasks.js +3 -0
- package/dist/discord/actions-tasks.test.js +418 -0
- package/dist/discord/actions.js +600 -0
- package/dist/discord/actions.test.js +522 -0
- package/dist/discord/allowed-mentions.js +3 -0
- package/dist/discord/allowed-mentions.test.js +17 -0
- package/dist/discord/allowlist.js +29 -0
- package/dist/discord/allowlist.test.js +24 -0
- package/dist/discord/audit-handler.js +191 -0
- package/dist/discord/audit-handler.test.js +361 -0
- package/dist/discord/bot.js +141 -0
- package/dist/discord/channel-context.js +181 -0
- package/dist/discord/defer-scheduler.js +45 -0
- package/dist/discord/destructive-confirmation.js +128 -0
- package/dist/discord/destructive-confirmation.test.js +49 -0
- package/dist/discord/discord-plan-auto-implement.test.js +18 -0
- package/dist/discord/durable-memory.js +145 -0
- package/dist/discord/durable-memory.test.js +281 -0
- package/dist/discord/durable-write-queue.js +4 -0
- package/dist/discord/file-download.js +308 -0
- package/dist/discord/file-download.test.js +303 -0
- package/dist/discord/forge-audit-verdict.js +140 -0
- package/dist/discord/forge-auto-implement.js +80 -0
- package/dist/discord/forge-auto-implement.test.js +110 -0
- package/dist/discord/forge-commands.js +698 -0
- package/dist/discord/forge-commands.test.js +1606 -0
- package/dist/discord/forge-plan-registry.js +68 -0
- package/dist/discord/forge-plan-registry.test.js +127 -0
- package/dist/discord/forum-count-sync.js +130 -0
- package/dist/discord/forum-count-sync.test.js +200 -0
- package/dist/discord/health-command.js +98 -0
- package/dist/discord/health-command.test.js +195 -0
- package/dist/discord/help-command.js +22 -0
- package/dist/discord/help-command.test.js +49 -0
- package/dist/discord/image-download.js +201 -0
- package/dist/discord/image-download.test.js +499 -0
- package/dist/discord/inflight-replies.js +228 -0
- package/dist/discord/inflight-replies.test.js +295 -0
- package/dist/discord/json-extract.js +110 -0
- package/dist/discord/keyed-queue.js +22 -0
- package/dist/discord/memory-commands.js +85 -0
- package/dist/discord/memory-commands.test.js +159 -0
- package/dist/discord/memory-timing.integration.test.js +159 -0
- package/dist/discord/message-coordinator.js +2347 -0
- package/dist/discord/message-coordinator.onboarding.test.js +183 -0
- package/dist/discord/message-coordinator.plan-run.test.js +264 -0
- package/dist/discord/message-history.js +53 -0
- package/dist/discord/message-history.test.js +95 -0
- package/dist/discord/models-command.js +59 -0
- package/dist/discord/models-command.test.js +150 -0
- package/dist/discord/nickname.test.js +76 -0
- package/dist/discord/onboarding-completion.js +55 -0
- package/dist/discord/onboarding-completion.test.js +176 -0
- package/dist/discord/output-common.js +178 -0
- package/dist/discord/output-common.test.js +198 -0
- package/dist/discord/output-utils.js +156 -0
- package/dist/discord/parse-identity-name.test.js +129 -0
- package/dist/discord/plan-commands.js +612 -0
- package/dist/discord/plan-commands.test.js +1622 -0
- package/dist/discord/plan-manager.js +1491 -0
- package/dist/discord/plan-manager.test.js +2380 -0
- package/dist/discord/plan-parser.js +110 -0
- package/dist/discord/plan-parser.test.js +63 -0
- package/dist/discord/plan-run-phase-start.js +20 -0
- package/dist/discord/plan-run-phase-start.test.js +29 -0
- package/dist/discord/platform-message.js +45 -0
- package/dist/discord/platform-message.test.js +110 -0
- package/dist/discord/prompt-common.js +240 -0
- package/dist/discord/prompt-common.test.js +423 -0
- package/dist/discord/reaction-handler.js +691 -0
- package/dist/discord/reaction-handler.test.js +1574 -0
- package/dist/discord/reaction-prompts.js +118 -0
- package/dist/discord/reaction-prompts.test.js +253 -0
- package/dist/discord/reply-reference.js +66 -0
- package/dist/discord/reply-reference.test.js +125 -0
- package/dist/discord/restart-command.js +143 -0
- package/dist/discord/restart-command.test.js +196 -0
- package/dist/discord/runtime-utils.js +43 -0
- package/dist/discord/runtime-utils.test.js +112 -0
- package/dist/discord/session-key.js +7 -0
- package/dist/discord/session-key.test.js +13 -0
- package/dist/discord/shortterm-memory.js +166 -0
- package/dist/discord/shortterm-memory.test.js +345 -0
- package/dist/discord/shutdown-context.js +122 -0
- package/dist/discord/shutdown-context.test.js +279 -0
- package/dist/discord/startup-profile.test.js +214 -0
- package/dist/discord/status-channel.js +190 -0
- package/dist/discord/status-channel.test.js +282 -0
- package/dist/discord/status-command.js +206 -0
- package/dist/discord/status-command.test.js +341 -0
- package/dist/discord/streaming-progress.js +107 -0
- package/dist/discord/streaming-progress.test.js +93 -0
- package/dist/discord/summarizer.js +89 -0
- package/dist/discord/summarizer.test.js +245 -0
- package/dist/discord/system-bootstrap.js +396 -0
- package/dist/discord/system-bootstrap.test.js +724 -0
- package/dist/discord/thread-context.js +169 -0
- package/dist/discord/thread-context.test.js +386 -0
- package/dist/discord/tool-aware-queue.js +116 -0
- package/dist/discord/tool-aware-queue.test.js +180 -0
- package/dist/discord/update-command.js +127 -0
- package/dist/discord/update-command.test.js +275 -0
- package/dist/discord/user-errors.js +40 -0
- package/dist/discord/user-errors.test.js +31 -0
- package/dist/discord/user-turn-to-durable.js +111 -0
- package/dist/discord/user-turn-to-durable.test.js +273 -0
- package/dist/discord-followup.test.js +677 -0
- package/dist/discord.channel-context.test.js +95 -0
- package/dist/discord.fail-closed.test.js +199 -0
- package/dist/discord.health-command.integration.test.js +140 -0
- package/dist/discord.js +190 -0
- package/dist/discord.prompt-context.test.js +1431 -0
- package/dist/discord.render.test.js +621 -0
- package/dist/discord.status-wiring.test.js +187 -0
- package/dist/engine/claudeCli.js +137 -0
- package/dist/engine/types.js +1 -0
- package/dist/group-queue.js +25 -0
- package/dist/health/credential-check.js +175 -0
- package/dist/health/credential-check.test.js +401 -0
- package/dist/health/startup-healing.js +139 -0
- package/dist/health/startup-healing.test.js +298 -0
- package/dist/identity.js +36 -0
- package/dist/index.js +1378 -0
- package/dist/logging/logger-like.js +1 -0
- package/dist/observability/memory-sampler.js +51 -0
- package/dist/observability/memory-sampler.test.js +93 -0
- package/dist/observability/metrics.js +88 -0
- package/dist/observability/metrics.test.js +42 -0
- package/dist/onboarding/onboarding-flow.js +246 -0
- package/dist/onboarding/onboarding-flow.test.js +238 -0
- package/dist/onboarding/onboarding-writer.js +102 -0
- package/dist/onboarding/onboarding-writer.test.js +143 -0
- package/dist/pidlock.js +187 -0
- package/dist/pidlock.test.js +128 -0
- package/dist/pipeline/engine.js +206 -0
- package/dist/pipeline/engine.test.js +771 -0
- package/dist/root-policy.js +21 -0
- package/dist/root-policy.test.js +55 -0
- package/dist/runtime/claude-code-cli.js +35 -0
- package/dist/runtime/claude-code-cli.test.js +1199 -0
- package/dist/runtime/cli-adapter.js +584 -0
- package/dist/runtime/cli-output-parsers.js +108 -0
- package/dist/runtime/cli-shared.js +96 -0
- package/dist/runtime/cli-shared.test.js +104 -0
- package/dist/runtime/cli-strategy.js +6 -0
- package/dist/runtime/codex-cli.js +16 -0
- package/dist/runtime/codex-cli.test.js +862 -0
- package/dist/runtime/concurrency-limit.js +80 -0
- package/dist/runtime/concurrency-limit.test.js +137 -0
- package/dist/runtime/gemini-cli.js +16 -0
- package/dist/runtime/gemini-cli.test.js +413 -0
- package/dist/runtime/long-running-process.js +415 -0
- package/dist/runtime/long-running-process.test.js +318 -0
- package/dist/runtime/model-smoke-helpers.js +160 -0
- package/dist/runtime/model-smoke.test.js +194 -0
- package/dist/runtime/model-tiers.js +33 -0
- package/dist/runtime/model-tiers.test.js +65 -0
- package/dist/runtime/openai-auth.js +151 -0
- package/dist/runtime/openai-auth.test.js +361 -0
- package/dist/runtime/openai-compat.js +178 -0
- package/dist/runtime/openai-compat.test.js +449 -0
- package/dist/runtime/process-pool.js +93 -0
- package/dist/runtime/process-pool.test.js +148 -0
- package/dist/runtime/registry.js +15 -0
- package/dist/runtime/registry.test.js +47 -0
- package/dist/runtime/session-scanner.js +186 -0
- package/dist/runtime/session-scanner.test.js +257 -0
- package/dist/runtime/strategies/claude-strategy.js +193 -0
- package/dist/runtime/strategies/codex-strategy.js +161 -0
- package/dist/runtime/strategies/gemini-strategy.js +64 -0
- package/dist/runtime/strategies/template-strategy.js +85 -0
- package/dist/runtime/tool-capabilities.js +27 -0
- package/dist/runtime/tool-capabilities.test.js +24 -0
- package/dist/runtime/tool-labels.js +48 -0
- package/dist/runtime/types.js +2 -0
- package/dist/sessionManager.js +47 -0
- package/dist/sessions.js +18 -0
- package/dist/tasks/architecture-contract.js +33 -0
- package/dist/tasks/architecture-contract.test.js +90 -0
- package/dist/tasks/auto-tag.js +50 -0
- package/dist/tasks/auto-tag.test.js +64 -0
- package/dist/tasks/bd-cli.js +164 -0
- package/dist/tasks/bd-cli.test.js +359 -0
- package/dist/tasks/bead-sync.js +1 -0
- package/dist/tasks/context-summary.js +27 -0
- package/dist/tasks/discord-sync.js +3 -0
- package/dist/tasks/discord-sync.test.js +685 -0
- package/dist/tasks/discord-types.js +4 -0
- package/dist/tasks/find-task-by-thread.test.js +36 -0
- package/dist/tasks/forum-guard.js +81 -0
- package/dist/tasks/forum-guard.test.js +192 -0
- package/dist/tasks/initialize.js +77 -0
- package/dist/tasks/initialize.test.js +263 -0
- package/dist/tasks/logger-types.js +1 -0
- package/dist/tasks/metrics-types.js +3 -0
- package/dist/tasks/migrate.js +33 -0
- package/dist/tasks/migrate.test.js +156 -0
- package/dist/tasks/path-defaults.js +67 -0
- package/dist/tasks/path-defaults.test.js +73 -0
- package/dist/tasks/runtime-types.js +1 -0
- package/dist/tasks/service.js +33 -0
- package/dist/tasks/service.test.js +51 -0
- package/dist/tasks/store.js +238 -0
- package/dist/tasks/store.test.js +417 -0
- package/dist/tasks/sync-context.js +1 -0
- package/dist/tasks/sync-contract.js +24 -0
- package/dist/tasks/sync-contract.test.js +25 -0
- package/dist/tasks/sync-coordinator-metrics.js +41 -0
- package/dist/tasks/sync-coordinator-retries.js +71 -0
- package/dist/tasks/sync-coordinator.js +96 -0
- package/dist/tasks/sync-coordinator.test.js +501 -0
- package/dist/tasks/sync-types.js +1 -0
- package/dist/tasks/sync-watcher.js +27 -0
- package/dist/tasks/sync-watcher.test.js +92 -0
- package/dist/tasks/tag-map.js +36 -0
- package/dist/tasks/tag-map.test.js +54 -0
- package/dist/tasks/task-action-contract.js +16 -0
- package/dist/tasks/task-action-contract.test.js +16 -0
- package/dist/tasks/task-action-executor.js +18 -0
- package/dist/tasks/task-action-executor.test.js +420 -0
- package/dist/tasks/task-action-mutation-helpers.js +17 -0
- package/dist/tasks/task-action-mutations.js +151 -0
- package/dist/tasks/task-action-prompt.js +62 -0
- package/dist/tasks/task-action-read-ops.js +73 -0
- package/dist/tasks/task-action-runner-types.js +1 -0
- package/dist/tasks/task-action-thread-sync.js +82 -0
- package/dist/tasks/task-actions.js +3 -0
- package/dist/tasks/task-cli.js +227 -0
- package/dist/tasks/task-context.js +1 -0
- package/dist/tasks/task-lifecycle.js +46 -0
- package/dist/tasks/task-lifecycle.test.js +35 -0
- package/dist/tasks/task-sync-apply-plan.js +95 -0
- package/dist/tasks/task-sync-apply-types.js +12 -0
- package/dist/tasks/task-sync-apply.js +319 -0
- package/dist/tasks/task-sync-cli.js +89 -0
- package/dist/tasks/task-sync-cli.test.js +70 -0
- package/dist/tasks/task-sync-engine.js +88 -0
- package/dist/tasks/task-sync-engine.test.js +934 -0
- package/dist/tasks/task-sync-phase-apply.js +171 -0
- package/dist/tasks/task-sync-pipeline.js +2 -0
- package/dist/tasks/task-sync-pipeline.test.js +265 -0
- package/dist/tasks/task-sync-reconcile-plan.js +182 -0
- package/dist/tasks/task-sync-reconcile.js +144 -0
- package/dist/tasks/task-sync.js +56 -0
- package/dist/tasks/task-sync.test.js +86 -0
- package/dist/tasks/thread-cache.js +42 -0
- package/dist/tasks/thread-cache.test.js +89 -0
- package/dist/tasks/thread-contracts.test.js +711 -0
- package/dist/tasks/thread-forum-ops.js +68 -0
- package/dist/tasks/thread-helpers.js +86 -0
- package/dist/tasks/thread-helpers.test.js +33 -0
- package/dist/tasks/thread-lifecycle-ops.js +144 -0
- package/dist/tasks/thread-ops-shared.js +21 -0
- package/dist/tasks/thread-ops.js +2 -0
- package/dist/tasks/types.js +20 -0
- package/dist/tasks/types.test.js +60 -0
- package/dist/test-setup.js +11 -0
- package/dist/test-setup.test.js +42 -0
- package/dist/transport/types.js +1 -0
- package/dist/validate.js +41 -0
- package/dist/validate.test.js +94 -0
- package/dist/version.js +15 -0
- package/dist/version.test.js +31 -0
- package/dist/webhook/server.js +199 -0
- package/dist/webhook/server.test.js +460 -0
- package/dist/workspace-bootstrap.js +135 -0
- package/dist/workspace-bootstrap.test.js +514 -0
- package/dist/workspace-permissions.js +134 -0
- package/dist/workspace-permissions.test.js +181 -0
- package/package.json +74 -0
- package/scripts/cron/cron-tag-map.json +9 -0
- package/scripts/tasks/tag-map.json +10 -0
- package/systemd/discoclaw.service +19 -0
- package/templates/recipes/integration.discoclaw-recipe.md +171 -0
- package/templates/workspace/AGENTS.md +217 -0
- package/templates/workspace/BOOTSTRAP.md +1 -0
- package/templates/workspace/HEARTBEAT.md +10 -0
- package/templates/workspace/IDENTITY.md +16 -0
- package/templates/workspace/MEMORY.md +24 -0
- package/templates/workspace/SOUL.md +52 -0
- package/templates/workspace/TOOLS.md +304 -0
- package/templates/workspace/USER.md +37 -0
|
@@ -0,0 +1,1574 @@
|
|
|
1
|
+
import { describe, expect, it, vi, afterEach } from 'vitest';
|
|
2
|
+
import { ChannelType } from 'discord.js';
|
|
3
|
+
import { createReactionAddHandler, createReactionRemoveHandler } from './reaction-handler.js';
|
|
4
|
+
import { inFlightReplyCount, _resetForTest as resetInFlight } from './inflight-replies.js';
|
|
5
|
+
import * as reactionPrompts from './reaction-prompts.js';
|
|
6
|
+
import * as abortRegistry from './abort-registry.js';
|
|
7
|
+
import * as forgePlanRegistry from './forge-plan-registry.js';
|
|
8
|
+
function makeMockRuntime(response) {
|
|
9
|
+
return {
|
|
10
|
+
id: 'claude_code',
|
|
11
|
+
capabilities: new Set(['streaming_text']),
|
|
12
|
+
async *invoke() {
|
|
13
|
+
yield { type: 'text_final', text: response };
|
|
14
|
+
yield { type: 'done' };
|
|
15
|
+
},
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
function makeMockRuntimeError(message) {
|
|
19
|
+
return {
|
|
20
|
+
id: 'claude_code',
|
|
21
|
+
capabilities: new Set(['streaming_text']),
|
|
22
|
+
async *invoke() {
|
|
23
|
+
yield { type: 'error', message };
|
|
24
|
+
yield { type: 'done' };
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
function mockLog() {
|
|
29
|
+
return { info: vi.fn(), warn: vi.fn(), error: vi.fn() };
|
|
30
|
+
}
|
|
31
|
+
function mockReplyObject() {
|
|
32
|
+
return { edit: vi.fn().mockResolvedValue(undefined), delete: vi.fn().mockResolvedValue(undefined) };
|
|
33
|
+
}
|
|
34
|
+
function mockMessage(overrides) {
|
|
35
|
+
const replyObj = mockReplyObject();
|
|
36
|
+
return {
|
|
37
|
+
id: 'msg-1',
|
|
38
|
+
content: 'Hello world',
|
|
39
|
+
channelId: 'ch-1',
|
|
40
|
+
guildId: 'guild-1',
|
|
41
|
+
createdTimestamp: Date.now(),
|
|
42
|
+
partial: false,
|
|
43
|
+
author: {
|
|
44
|
+
id: 'author-1',
|
|
45
|
+
username: 'Alice',
|
|
46
|
+
displayName: 'Alice',
|
|
47
|
+
},
|
|
48
|
+
client: {
|
|
49
|
+
user: { id: 'bot-1' },
|
|
50
|
+
},
|
|
51
|
+
guild: {
|
|
52
|
+
channels: {
|
|
53
|
+
cache: { get: vi.fn(), find: vi.fn() },
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
channel: {
|
|
57
|
+
id: 'ch-1',
|
|
58
|
+
name: 'general',
|
|
59
|
+
isThread: () => false,
|
|
60
|
+
send: vi.fn().mockResolvedValue(undefined),
|
|
61
|
+
},
|
|
62
|
+
attachments: { size: 0, values: () => [] },
|
|
63
|
+
embeds: [],
|
|
64
|
+
reply: vi.fn().mockResolvedValue(replyObj),
|
|
65
|
+
_replyObj: replyObj,
|
|
66
|
+
fetch: vi.fn(),
|
|
67
|
+
...overrides,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
function mockReaction(overrides) {
|
|
71
|
+
return {
|
|
72
|
+
partial: false,
|
|
73
|
+
emoji: { name: '👀' },
|
|
74
|
+
message: mockMessage(),
|
|
75
|
+
fetch: vi.fn(),
|
|
76
|
+
...overrides,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
function mockUser(overrides) {
|
|
80
|
+
return {
|
|
81
|
+
id: 'user-1',
|
|
82
|
+
username: 'David',
|
|
83
|
+
displayName: 'David',
|
|
84
|
+
partial: false,
|
|
85
|
+
...overrides,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
function mockQueue() {
|
|
89
|
+
return {
|
|
90
|
+
run: vi.fn(async (_key, fn) => fn()),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
function makeParams(overrides) {
|
|
94
|
+
return {
|
|
95
|
+
allowUserIds: new Set(['user-1']),
|
|
96
|
+
allowChannelIds: undefined,
|
|
97
|
+
botDisplayName: 'TestBot',
|
|
98
|
+
log: mockLog(),
|
|
99
|
+
discordChannelContext: undefined,
|
|
100
|
+
requireChannelContext: false,
|
|
101
|
+
autoIndexChannelContext: false,
|
|
102
|
+
autoJoinThreads: false,
|
|
103
|
+
useRuntimeSessions: false,
|
|
104
|
+
runtime: makeMockRuntime('Reaction response!'),
|
|
105
|
+
sessionManager: { getOrCreate: vi.fn().mockResolvedValue('session-1') },
|
|
106
|
+
workspaceCwd: '/tmp/workspace',
|
|
107
|
+
projectCwd: '/tmp',
|
|
108
|
+
groupsDir: '/tmp/groups',
|
|
109
|
+
useGroupDirCwd: false,
|
|
110
|
+
runtimeModel: 'opus',
|
|
111
|
+
runtimeTools: ['Bash', 'Read'],
|
|
112
|
+
runtimeTimeoutMs: 30_000,
|
|
113
|
+
discordActionsEnabled: false,
|
|
114
|
+
discordActionsChannels: false,
|
|
115
|
+
discordActionsMessaging: false,
|
|
116
|
+
discordActionsGuild: false,
|
|
117
|
+
discordActionsModeration: false,
|
|
118
|
+
discordActionsPolls: false,
|
|
119
|
+
discordActionsTasks: false,
|
|
120
|
+
discordActionsCrons: false,
|
|
121
|
+
discordActionsBotProfile: false,
|
|
122
|
+
messageHistoryBudget: 0,
|
|
123
|
+
summaryEnabled: false,
|
|
124
|
+
summaryModel: 'haiku',
|
|
125
|
+
summaryMaxChars: 2000,
|
|
126
|
+
summaryEveryNTurns: 5,
|
|
127
|
+
summaryDataDir: '/tmp/summary',
|
|
128
|
+
summaryToDurableEnabled: false,
|
|
129
|
+
shortTermMemoryEnabled: false,
|
|
130
|
+
shortTermDataDir: '/tmp/shortterm',
|
|
131
|
+
shortTermMaxEntries: 20,
|
|
132
|
+
shortTermMaxAgeMs: 21600000,
|
|
133
|
+
shortTermInjectMaxChars: 1000,
|
|
134
|
+
durableMemoryEnabled: false,
|
|
135
|
+
durableDataDir: '/tmp/durable',
|
|
136
|
+
durableInjectMaxChars: 2000,
|
|
137
|
+
durableMaxItems: 200,
|
|
138
|
+
memoryCommandsEnabled: false,
|
|
139
|
+
statusChannel: undefined,
|
|
140
|
+
toolAwareStreaming: false,
|
|
141
|
+
actionFollowupDepth: 0,
|
|
142
|
+
reactionHandlerEnabled: true,
|
|
143
|
+
reactionRemoveHandlerEnabled: false,
|
|
144
|
+
reactionMaxAgeMs: 24 * 60 * 60 * 1000,
|
|
145
|
+
streamStallWarningMs: 0,
|
|
146
|
+
...overrides,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
describe('createReactionAddHandler', () => {
|
|
150
|
+
it('ignores self-reactions (bot reacting to its own)', async () => {
|
|
151
|
+
const params = makeParams();
|
|
152
|
+
const queue = mockQueue();
|
|
153
|
+
const handler = createReactionAddHandler(params, queue);
|
|
154
|
+
const reaction = mockReaction();
|
|
155
|
+
// User ID matches bot ID.
|
|
156
|
+
const user = mockUser({ id: 'bot-1' });
|
|
157
|
+
await handler(reaction, user);
|
|
158
|
+
expect(queue.run).not.toHaveBeenCalled();
|
|
159
|
+
});
|
|
160
|
+
it('ignores non-allowlisted users', async () => {
|
|
161
|
+
const params = makeParams({ allowUserIds: new Set(['other-user']) });
|
|
162
|
+
const queue = mockQueue();
|
|
163
|
+
const handler = createReactionAddHandler(params, queue);
|
|
164
|
+
await handler(mockReaction(), mockUser());
|
|
165
|
+
expect(queue.run).not.toHaveBeenCalled();
|
|
166
|
+
});
|
|
167
|
+
it('ignores reactions in non-allowed channels', async () => {
|
|
168
|
+
const params = makeParams({ allowChannelIds: new Set(['other-channel']) });
|
|
169
|
+
const queue = mockQueue();
|
|
170
|
+
const handler = createReactionAddHandler(params, queue);
|
|
171
|
+
await handler(mockReaction(), mockUser());
|
|
172
|
+
expect(queue.run).not.toHaveBeenCalled();
|
|
173
|
+
});
|
|
174
|
+
it('ignores DM reactions (guildId null)', async () => {
|
|
175
|
+
const params = makeParams();
|
|
176
|
+
const queue = mockQueue();
|
|
177
|
+
const handler = createReactionAddHandler(params, queue);
|
|
178
|
+
const reaction = mockReaction({
|
|
179
|
+
message: mockMessage({ guildId: null }),
|
|
180
|
+
});
|
|
181
|
+
await handler(reaction, mockUser());
|
|
182
|
+
expect(queue.run).not.toHaveBeenCalled();
|
|
183
|
+
});
|
|
184
|
+
it('ignores stale messages older than reactionMaxAgeMs', async () => {
|
|
185
|
+
const params = makeParams({ reactionMaxAgeMs: 1000 });
|
|
186
|
+
const queue = mockQueue();
|
|
187
|
+
const handler = createReactionAddHandler(params, queue);
|
|
188
|
+
const reaction = mockReaction({
|
|
189
|
+
message: mockMessage({ createdTimestamp: Date.now() - 5000 }),
|
|
190
|
+
});
|
|
191
|
+
await handler(reaction, mockUser());
|
|
192
|
+
expect(queue.run).not.toHaveBeenCalled();
|
|
193
|
+
});
|
|
194
|
+
it('happy path — allowlisted user reacts, runtime responds, reply posted', async () => {
|
|
195
|
+
const params = makeParams();
|
|
196
|
+
const queue = mockQueue();
|
|
197
|
+
const handler = createReactionAddHandler(params, queue);
|
|
198
|
+
const reaction = mockReaction();
|
|
199
|
+
await handler(reaction, mockUser());
|
|
200
|
+
expect(queue.run).toHaveBeenCalledOnce();
|
|
201
|
+
// Immediate placeholder reply.
|
|
202
|
+
expect(reaction.message.reply).toHaveBeenCalledOnce();
|
|
203
|
+
expect(reaction.message.reply.mock.calls[0][0].content).toMatch(/Thinking/);
|
|
204
|
+
// Final output via edit on the reply object.
|
|
205
|
+
const replyObj = reaction.message._replyObj;
|
|
206
|
+
const lastEditCall = replyObj.edit.mock.calls[replyObj.edit.mock.calls.length - 1];
|
|
207
|
+
expect(lastEditCall[0].content).toContain('Reaction response!');
|
|
208
|
+
});
|
|
209
|
+
it('prompt includes emoji name, original message content, reacting user, and channel label', async () => {
|
|
210
|
+
const invokeSpy = vi.fn();
|
|
211
|
+
const runtime = {
|
|
212
|
+
id: 'claude_code',
|
|
213
|
+
capabilities: new Set(['streaming_text']),
|
|
214
|
+
async *invoke(p) {
|
|
215
|
+
invokeSpy(p);
|
|
216
|
+
yield { type: 'text_final', text: 'ok' };
|
|
217
|
+
yield { type: 'done' };
|
|
218
|
+
},
|
|
219
|
+
};
|
|
220
|
+
const params = makeParams({ runtime });
|
|
221
|
+
const queue = mockQueue();
|
|
222
|
+
const handler = createReactionAddHandler(params, queue);
|
|
223
|
+
const reaction = mockReaction({ emoji: { name: '🔥' } });
|
|
224
|
+
reaction.message.content = 'Some important message';
|
|
225
|
+
reaction.message.channel.name = 'dev-chat';
|
|
226
|
+
await handler(reaction, mockUser({ username: 'Bob', displayName: 'Bob' }));
|
|
227
|
+
expect(invokeSpy).toHaveBeenCalledOnce();
|
|
228
|
+
const prompt = invokeSpy.mock.calls[0][0].prompt;
|
|
229
|
+
expect(prompt).toContain('🔥');
|
|
230
|
+
expect(prompt).toContain('Some important message');
|
|
231
|
+
expect(prompt).toContain('Bob');
|
|
232
|
+
expect(prompt).toContain('#');
|
|
233
|
+
// Boundary instruction appears before the reaction event line.
|
|
234
|
+
const boundaryIdx = prompt.indexOf('internal system context');
|
|
235
|
+
const reactionIdx = prompt.indexOf('Reaction event:');
|
|
236
|
+
expect(boundaryIdx).toBeGreaterThan(-1);
|
|
237
|
+
expect(boundaryIdx).toBeLessThan(reactionIdx);
|
|
238
|
+
});
|
|
239
|
+
it('image attachments are downloaded and passed to runtime.invoke', async () => {
|
|
240
|
+
const invokeSpy = vi.fn();
|
|
241
|
+
const runtime = {
|
|
242
|
+
id: 'claude_code',
|
|
243
|
+
capabilities: new Set(['streaming_text']),
|
|
244
|
+
async *invoke(p) {
|
|
245
|
+
invokeSpy(p);
|
|
246
|
+
yield { type: 'text_final', text: 'ok' };
|
|
247
|
+
yield { type: 'done' };
|
|
248
|
+
},
|
|
249
|
+
};
|
|
250
|
+
const params = makeParams({ runtime });
|
|
251
|
+
const queue = mockQueue();
|
|
252
|
+
const handler = createReactionAddHandler(params, queue);
|
|
253
|
+
// Mock global fetch for the image download
|
|
254
|
+
const originalFetch = globalThis.fetch;
|
|
255
|
+
const imgData = (() => { const b = Buffer.alloc(45); Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]).copy(b); return b; })();
|
|
256
|
+
globalThis.fetch = vi.fn().mockResolvedValue({
|
|
257
|
+
ok: true,
|
|
258
|
+
arrayBuffer: () => Promise.resolve(imgData.buffer.slice(imgData.byteOffset, imgData.byteOffset + imgData.byteLength)),
|
|
259
|
+
});
|
|
260
|
+
try {
|
|
261
|
+
const reaction = mockReaction();
|
|
262
|
+
reaction.message.attachments = {
|
|
263
|
+
size: 1,
|
|
264
|
+
values: () => [{
|
|
265
|
+
url: 'https://cdn.discordapp.com/attachments/123/456/photo.png',
|
|
266
|
+
name: 'photo.png',
|
|
267
|
+
contentType: 'image/png',
|
|
268
|
+
size: 100,
|
|
269
|
+
}],
|
|
270
|
+
};
|
|
271
|
+
await handler(reaction, mockUser());
|
|
272
|
+
// Images should be passed in invoke params, not as URL text in prompt
|
|
273
|
+
const invokeParams = invokeSpy.mock.calls[0][0];
|
|
274
|
+
expect(invokeParams.images).toBeDefined();
|
|
275
|
+
expect(invokeParams.images).toHaveLength(1);
|
|
276
|
+
expect(invokeParams.images[0].mediaType).toBe('image/png');
|
|
277
|
+
// Prompt should NOT contain the raw URL
|
|
278
|
+
expect(invokeParams.prompt).not.toContain('https://cdn.discordapp.com');
|
|
279
|
+
}
|
|
280
|
+
finally {
|
|
281
|
+
globalThis.fetch = originalFetch;
|
|
282
|
+
}
|
|
283
|
+
});
|
|
284
|
+
it('prompt includes durable memory when enabled and store has items', async () => {
|
|
285
|
+
// Write a real durable memory file so the handler loads it without mocking.
|
|
286
|
+
const os = await import('node:os');
|
|
287
|
+
const fsP = await import('node:fs/promises');
|
|
288
|
+
const pathM = await import('node:path');
|
|
289
|
+
const tmpDir = await fsP.mkdtemp(pathM.join(os.tmpdir(), 'durable-'));
|
|
290
|
+
const store = {
|
|
291
|
+
version: 1,
|
|
292
|
+
updatedAt: Date.now(),
|
|
293
|
+
items: [{
|
|
294
|
+
id: 'test-1',
|
|
295
|
+
kind: 'fact',
|
|
296
|
+
text: 'User loves TypeScript',
|
|
297
|
+
tags: [],
|
|
298
|
+
status: 'active',
|
|
299
|
+
source: { type: 'manual' },
|
|
300
|
+
createdAt: Date.now(),
|
|
301
|
+
updatedAt: Date.now(),
|
|
302
|
+
}],
|
|
303
|
+
};
|
|
304
|
+
await fsP.writeFile(pathM.join(tmpDir, 'user-1.json'), JSON.stringify(store), 'utf8');
|
|
305
|
+
const invokeSpy = vi.fn();
|
|
306
|
+
const runtime = {
|
|
307
|
+
id: 'claude_code',
|
|
308
|
+
capabilities: new Set(['streaming_text']),
|
|
309
|
+
async *invoke(p) {
|
|
310
|
+
invokeSpy(p);
|
|
311
|
+
yield { type: 'text_final', text: 'ok' };
|
|
312
|
+
yield { type: 'done' };
|
|
313
|
+
},
|
|
314
|
+
};
|
|
315
|
+
const params = makeParams({ runtime, durableMemoryEnabled: true, durableDataDir: tmpDir });
|
|
316
|
+
const queue = mockQueue();
|
|
317
|
+
const handler = createReactionAddHandler(params, queue);
|
|
318
|
+
await handler(mockReaction(), mockUser());
|
|
319
|
+
const prompt = invokeSpy.mock.calls[0][0].prompt;
|
|
320
|
+
expect(prompt).toContain('Durable memory');
|
|
321
|
+
expect(prompt).toContain('User loves TypeScript');
|
|
322
|
+
await fsP.rm(tmpDir, { recursive: true });
|
|
323
|
+
});
|
|
324
|
+
it('Discord actions parsed and executed from response, results appended to output', async () => {
|
|
325
|
+
const runtime = {
|
|
326
|
+
id: 'claude_code',
|
|
327
|
+
capabilities: new Set(['streaming_text']),
|
|
328
|
+
async *invoke() {
|
|
329
|
+
yield { type: 'text_final', text: 'Here is my response\n\n<discord-action>{"type":"react","channelId":"ch-1","messageId":"msg-1","emoji":"✅"}</discord-action>' };
|
|
330
|
+
yield { type: 'done' };
|
|
331
|
+
},
|
|
332
|
+
};
|
|
333
|
+
const params = makeParams({
|
|
334
|
+
runtime,
|
|
335
|
+
discordActionsEnabled: true,
|
|
336
|
+
discordActionsMessaging: true,
|
|
337
|
+
});
|
|
338
|
+
const queue = mockQueue();
|
|
339
|
+
const handler = createReactionAddHandler(params, queue);
|
|
340
|
+
const reaction = mockReaction();
|
|
341
|
+
await handler(reaction, mockUser());
|
|
342
|
+
// Placeholder posted first.
|
|
343
|
+
expect(reaction.message.reply).toHaveBeenCalledOnce();
|
|
344
|
+
expect(reaction.message.reply.mock.calls[0][0].content).toMatch(/Thinking/);
|
|
345
|
+
// Final output via edit.
|
|
346
|
+
const replyObj = reaction.message._replyObj;
|
|
347
|
+
const lastEditCall = replyObj.edit.mock.calls[replyObj.edit.mock.calls.length - 1];
|
|
348
|
+
const replyContent = lastEditCall[0].content;
|
|
349
|
+
// The action block should be stripped from the clean text.
|
|
350
|
+
expect(replyContent).not.toContain('<discord-action>');
|
|
351
|
+
// Action results (Done: or Failed:) should be appended.
|
|
352
|
+
expect(replyContent).toMatch(/Done:|Failed:/);
|
|
353
|
+
});
|
|
354
|
+
it('suppresses sendMessage targeting parent forum when reaction is in a forum thread', async () => {
|
|
355
|
+
const runtime = {
|
|
356
|
+
id: 'claude_code',
|
|
357
|
+
capabilities: new Set(['streaming_text']),
|
|
358
|
+
async *invoke() {
|
|
359
|
+
yield { type: 'text_final', text: 'Here is my response.\n\n<discord-action>{"type":"sendMessage","channel":"forum-parent-1","content":"hello"}</discord-action>' };
|
|
360
|
+
yield { type: 'done' };
|
|
361
|
+
},
|
|
362
|
+
};
|
|
363
|
+
const params = makeParams({
|
|
364
|
+
runtime,
|
|
365
|
+
discordActionsEnabled: true,
|
|
366
|
+
discordActionsMessaging: true,
|
|
367
|
+
});
|
|
368
|
+
const queue = mockQueue();
|
|
369
|
+
const handler = createReactionAddHandler(params, queue);
|
|
370
|
+
// Build a guild with the forum channel in the cache.
|
|
371
|
+
const forumCh = {
|
|
372
|
+
id: 'forum-parent-1',
|
|
373
|
+
name: 'beads',
|
|
374
|
+
type: ChannelType.GuildForum,
|
|
375
|
+
send: vi.fn().mockResolvedValue(undefined),
|
|
376
|
+
};
|
|
377
|
+
const threadChannel = {
|
|
378
|
+
id: 'thread-1',
|
|
379
|
+
name: 'my-thread',
|
|
380
|
+
parentId: 'forum-parent-1',
|
|
381
|
+
isThread: () => true,
|
|
382
|
+
joinable: false,
|
|
383
|
+
joined: true,
|
|
384
|
+
parent: { name: 'beads' },
|
|
385
|
+
send: vi.fn().mockResolvedValue(undefined),
|
|
386
|
+
};
|
|
387
|
+
const channelsMap = new Map([
|
|
388
|
+
['forum-parent-1', forumCh],
|
|
389
|
+
['thread-1', threadChannel],
|
|
390
|
+
]);
|
|
391
|
+
const guild = {
|
|
392
|
+
channels: {
|
|
393
|
+
cache: {
|
|
394
|
+
get: (id) => channelsMap.get(id),
|
|
395
|
+
find: (fn) => {
|
|
396
|
+
for (const ch of channelsMap.values())
|
|
397
|
+
if (fn(ch))
|
|
398
|
+
return ch;
|
|
399
|
+
return undefined;
|
|
400
|
+
},
|
|
401
|
+
values: () => channelsMap.values(),
|
|
402
|
+
},
|
|
403
|
+
},
|
|
404
|
+
};
|
|
405
|
+
const reaction = mockReaction({
|
|
406
|
+
message: mockMessage({ channel: threadChannel, channelId: 'thread-1', guild }),
|
|
407
|
+
});
|
|
408
|
+
await handler(reaction, mockUser());
|
|
409
|
+
// The reply should contain prose and NOT contain a Failed: line or forum channel error text.
|
|
410
|
+
const replyObj = reaction.message._replyObj;
|
|
411
|
+
const lastEditCall = replyObj.edit.mock.calls[replyObj.edit.mock.calls.length - 1];
|
|
412
|
+
const replyContent = lastEditCall[0].content;
|
|
413
|
+
expect(replyContent).toContain('Here is my response.');
|
|
414
|
+
expect(replyContent).not.toContain('Failed:');
|
|
415
|
+
expect(replyContent).not.toContain('forum channel');
|
|
416
|
+
// Reply should NOT have been deleted (response was posted, not suppressed as empty).
|
|
417
|
+
expect(replyObj.delete).not.toHaveBeenCalled();
|
|
418
|
+
// Forum channel's .send() should NOT have been called.
|
|
419
|
+
expect(forumCh.send).not.toHaveBeenCalled();
|
|
420
|
+
});
|
|
421
|
+
it('suppresses sendMessage Done line from posted output', async () => {
|
|
422
|
+
const runtime = {
|
|
423
|
+
id: 'claude_code',
|
|
424
|
+
capabilities: new Set(['streaming_text']),
|
|
425
|
+
async *invoke() {
|
|
426
|
+
yield { type: 'text_final', text: 'Sending a message for you.\n\n<discord-action>{"type":"sendMessage","channel":"general","content":"hello"}</discord-action>' };
|
|
427
|
+
yield { type: 'done' };
|
|
428
|
+
},
|
|
429
|
+
};
|
|
430
|
+
const params = makeParams({
|
|
431
|
+
runtime,
|
|
432
|
+
discordActionsEnabled: true,
|
|
433
|
+
discordActionsMessaging: true,
|
|
434
|
+
});
|
|
435
|
+
const queue = mockQueue();
|
|
436
|
+
const handler = createReactionAddHandler(params, queue);
|
|
437
|
+
// Build a mock channel that resolveChannel can find by name.
|
|
438
|
+
const targetChannel = { id: 'ch-target', name: 'general', type: ChannelType.GuildText, send: vi.fn().mockResolvedValue({ id: 'sent-1' }) };
|
|
439
|
+
const reaction = mockReaction({
|
|
440
|
+
message: mockMessage({
|
|
441
|
+
guild: {
|
|
442
|
+
channels: {
|
|
443
|
+
cache: {
|
|
444
|
+
get: vi.fn(),
|
|
445
|
+
find: vi.fn((pred) => pred(targetChannel) ? targetChannel : undefined),
|
|
446
|
+
},
|
|
447
|
+
},
|
|
448
|
+
},
|
|
449
|
+
}),
|
|
450
|
+
});
|
|
451
|
+
await handler(reaction, mockUser());
|
|
452
|
+
const replyObj = reaction.message._replyObj;
|
|
453
|
+
const lastEditCall = replyObj.edit.mock.calls[replyObj.edit.mock.calls.length - 1];
|
|
454
|
+
const replyContent = lastEditCall[0].content;
|
|
455
|
+
// Should NOT contain 'Done: Sent message'.
|
|
456
|
+
expect(replyContent).not.toContain('Done: Sent message');
|
|
457
|
+
// Clean text should still be present.
|
|
458
|
+
expect(replyContent).toContain('Sending a message for you.');
|
|
459
|
+
});
|
|
460
|
+
it('deletes placeholder when sendMessage-only with no prose', async () => {
|
|
461
|
+
const runtime = {
|
|
462
|
+
id: 'claude_code',
|
|
463
|
+
capabilities: new Set(['streaming_text']),
|
|
464
|
+
async *invoke() {
|
|
465
|
+
yield { type: 'text_final', text: '<discord-action>{"type":"sendMessage","channel":"general","content":"hello"}</discord-action>' };
|
|
466
|
+
yield { type: 'done' };
|
|
467
|
+
},
|
|
468
|
+
};
|
|
469
|
+
const params = makeParams({
|
|
470
|
+
runtime,
|
|
471
|
+
discordActionsEnabled: true,
|
|
472
|
+
discordActionsMessaging: true,
|
|
473
|
+
});
|
|
474
|
+
const queue = mockQueue();
|
|
475
|
+
const handler = createReactionAddHandler(params, queue);
|
|
476
|
+
const targetChannel = { id: 'ch-target', name: 'general', type: ChannelType.GuildText, send: vi.fn().mockResolvedValue({ id: 'sent-1' }) };
|
|
477
|
+
const replyObj = { edit: vi.fn().mockResolvedValue(undefined), delete: vi.fn().mockResolvedValue(undefined) };
|
|
478
|
+
const reaction = mockReaction({
|
|
479
|
+
message: mockMessage({
|
|
480
|
+
guild: {
|
|
481
|
+
channels: {
|
|
482
|
+
cache: {
|
|
483
|
+
get: vi.fn(),
|
|
484
|
+
find: vi.fn((pred) => pred(targetChannel) ? targetChannel : undefined),
|
|
485
|
+
},
|
|
486
|
+
},
|
|
487
|
+
},
|
|
488
|
+
reply: vi.fn().mockResolvedValue(replyObj),
|
|
489
|
+
_replyObj: replyObj,
|
|
490
|
+
}),
|
|
491
|
+
});
|
|
492
|
+
await handler(reaction, mockUser());
|
|
493
|
+
// The sendMessage action should have fired.
|
|
494
|
+
expect(targetChannel.send).toHaveBeenCalledOnce();
|
|
495
|
+
// Placeholder should have been deleted (no output to display).
|
|
496
|
+
expect(replyObj.delete).toHaveBeenCalledOnce();
|
|
497
|
+
});
|
|
498
|
+
it('fetches partial reaction before processing', async () => {
|
|
499
|
+
const params = makeParams();
|
|
500
|
+
const queue = mockQueue();
|
|
501
|
+
const handler = createReactionAddHandler(params, queue);
|
|
502
|
+
const reaction = mockReaction({ partial: true });
|
|
503
|
+
await handler(reaction, mockUser());
|
|
504
|
+
expect(reaction.fetch).toHaveBeenCalledOnce();
|
|
505
|
+
expect(queue.run).toHaveBeenCalledOnce();
|
|
506
|
+
});
|
|
507
|
+
it('fetches partial message before processing', async () => {
|
|
508
|
+
const params = makeParams();
|
|
509
|
+
const queue = mockQueue();
|
|
510
|
+
const handler = createReactionAddHandler(params, queue);
|
|
511
|
+
const msg = mockMessage({ partial: true });
|
|
512
|
+
const reaction = mockReaction({ message: msg });
|
|
513
|
+
await handler(reaction, mockUser());
|
|
514
|
+
expect(msg.fetch).toHaveBeenCalledOnce();
|
|
515
|
+
expect(queue.run).toHaveBeenCalledOnce();
|
|
516
|
+
});
|
|
517
|
+
it('handles partial reaction fetch failure gracefully', async () => {
|
|
518
|
+
const params = makeParams();
|
|
519
|
+
const queue = mockQueue();
|
|
520
|
+
const handler = createReactionAddHandler(params, queue);
|
|
521
|
+
const reaction = mockReaction({
|
|
522
|
+
partial: true,
|
|
523
|
+
fetch: vi.fn().mockRejectedValue(new Error('Unknown Reaction')),
|
|
524
|
+
});
|
|
525
|
+
await handler(reaction, mockUser());
|
|
526
|
+
expect(params.log?.warn).toHaveBeenCalled();
|
|
527
|
+
expect(queue.run).not.toHaveBeenCalled();
|
|
528
|
+
});
|
|
529
|
+
it('handles partial message fetch failure gracefully', async () => {
|
|
530
|
+
const params = makeParams();
|
|
531
|
+
const queue = mockQueue();
|
|
532
|
+
const handler = createReactionAddHandler(params, queue);
|
|
533
|
+
const msg = mockMessage({
|
|
534
|
+
partial: true,
|
|
535
|
+
fetch: vi.fn().mockRejectedValue(new Error('Unknown Message')),
|
|
536
|
+
});
|
|
537
|
+
const reaction = mockReaction({ message: msg });
|
|
538
|
+
await handler(reaction, mockUser());
|
|
539
|
+
expect(params.log?.warn).toHaveBeenCalled();
|
|
540
|
+
expect(queue.run).not.toHaveBeenCalled();
|
|
541
|
+
});
|
|
542
|
+
it('handles runtime error (logged, status posted)', async () => {
|
|
543
|
+
const statusPoster = {
|
|
544
|
+
online: vi.fn(),
|
|
545
|
+
offline: vi.fn(),
|
|
546
|
+
runtimeError: vi.fn(),
|
|
547
|
+
handlerError: vi.fn(),
|
|
548
|
+
actionFailed: vi.fn(),
|
|
549
|
+
taskSyncComplete: vi.fn(),
|
|
550
|
+
};
|
|
551
|
+
const statusRef = { current: statusPoster };
|
|
552
|
+
const params = makeParams({ runtime: makeMockRuntimeError('timeout reached') });
|
|
553
|
+
const queue = mockQueue();
|
|
554
|
+
const handler = createReactionAddHandler(params, queue, statusRef);
|
|
555
|
+
const reaction = mockReaction();
|
|
556
|
+
await handler(reaction, mockUser());
|
|
557
|
+
expect(params.log?.error).toHaveBeenCalled();
|
|
558
|
+
expect(statusPoster.runtimeError).toHaveBeenCalledOnce();
|
|
559
|
+
// Placeholder first, then error via edit on the reply object.
|
|
560
|
+
expect(reaction.message.reply).toHaveBeenCalledOnce();
|
|
561
|
+
expect(reaction.message.reply.mock.calls[0][0].content).toMatch(/Thinking/);
|
|
562
|
+
const replyObj = reaction.message._replyObj;
|
|
563
|
+
const lastEditCall = replyObj.edit.mock.calls[replyObj.edit.mock.calls.length - 1];
|
|
564
|
+
expect(lastEditCall[0].content).toContain('Runtime error: timeout reached');
|
|
565
|
+
});
|
|
566
|
+
it('joins thread before replying when autoJoinThreads is enabled', async () => {
|
|
567
|
+
const joinFn = vi.fn().mockResolvedValue(undefined);
|
|
568
|
+
const params = makeParams({ autoJoinThreads: true });
|
|
569
|
+
const queue = mockQueue();
|
|
570
|
+
const handler = createReactionAddHandler(params, queue);
|
|
571
|
+
const threadChannel = {
|
|
572
|
+
id: 'thread-1',
|
|
573
|
+
name: 'my-thread',
|
|
574
|
+
parentId: 'ch-1',
|
|
575
|
+
isThread: () => true,
|
|
576
|
+
joinable: true,
|
|
577
|
+
joined: false,
|
|
578
|
+
join: joinFn,
|
|
579
|
+
parent: { name: 'general' },
|
|
580
|
+
send: vi.fn().mockResolvedValue(undefined),
|
|
581
|
+
};
|
|
582
|
+
const reaction = mockReaction({
|
|
583
|
+
message: mockMessage({ channel: threadChannel, channelId: 'thread-1' }),
|
|
584
|
+
});
|
|
585
|
+
await handler(reaction, mockUser());
|
|
586
|
+
expect(joinFn).toHaveBeenCalledOnce();
|
|
587
|
+
// Placeholder reply posted.
|
|
588
|
+
expect(reaction.message.reply).toHaveBeenCalledOnce();
|
|
589
|
+
expect(reaction.message.reply.mock.calls[0][0].content).toMatch(/Thinking/);
|
|
590
|
+
});
|
|
591
|
+
it('passes addDirs to runtime.invoke when useGroupDirCwd is active', async () => {
|
|
592
|
+
const invokeSpy = vi.fn();
|
|
593
|
+
const runtime = {
|
|
594
|
+
id: 'claude_code',
|
|
595
|
+
capabilities: new Set(['streaming_text']),
|
|
596
|
+
async *invoke(p) {
|
|
597
|
+
invokeSpy(p);
|
|
598
|
+
yield { type: 'text_final', text: 'ok' };
|
|
599
|
+
yield { type: 'done' };
|
|
600
|
+
},
|
|
601
|
+
};
|
|
602
|
+
const params = makeParams({ runtime, useGroupDirCwd: true });
|
|
603
|
+
const queue = mockQueue();
|
|
604
|
+
const handler = createReactionAddHandler(params, queue);
|
|
605
|
+
await handler(mockReaction(), mockUser());
|
|
606
|
+
expect(invokeSpy).toHaveBeenCalledOnce();
|
|
607
|
+
const invokeParams = invokeSpy.mock.calls[0][0];
|
|
608
|
+
expect(invokeParams.addDirs).toBeDefined();
|
|
609
|
+
expect(invokeParams.addDirs).toContain('/tmp/workspace');
|
|
610
|
+
});
|
|
611
|
+
it('passes session ID to runtime.invoke when useRuntimeSessions is enabled', async () => {
|
|
612
|
+
const invokeSpy = vi.fn();
|
|
613
|
+
const runtime = {
|
|
614
|
+
id: 'claude_code',
|
|
615
|
+
capabilities: new Set(['streaming_text']),
|
|
616
|
+
async *invoke(p) {
|
|
617
|
+
invokeSpy(p);
|
|
618
|
+
yield { type: 'text_final', text: 'ok' };
|
|
619
|
+
yield { type: 'done' };
|
|
620
|
+
},
|
|
621
|
+
};
|
|
622
|
+
const sessionManager = { getOrCreate: vi.fn().mockResolvedValue('ses-abc') };
|
|
623
|
+
const params = makeParams({ runtime, useRuntimeSessions: true, sessionManager: sessionManager });
|
|
624
|
+
const queue = mockQueue();
|
|
625
|
+
const handler = createReactionAddHandler(params, queue);
|
|
626
|
+
await handler(mockReaction(), mockUser());
|
|
627
|
+
expect(sessionManager.getOrCreate).toHaveBeenCalledOnce();
|
|
628
|
+
expect(invokeSpy).toHaveBeenCalledOnce();
|
|
629
|
+
expect(invokeSpy.mock.calls[0][0].sessionId).toBe('ses-abc');
|
|
630
|
+
});
|
|
631
|
+
it('suppresses trivial responses (e.g. HEARTBEAT_OK) and deletes placeholder', async () => {
|
|
632
|
+
const params = makeParams({ runtime: makeMockRuntime('HEARTBEAT_OK') });
|
|
633
|
+
const queue = mockQueue();
|
|
634
|
+
const handler = createReactionAddHandler(params, queue);
|
|
635
|
+
const replyObj = { edit: vi.fn().mockResolvedValue(undefined), delete: vi.fn().mockResolvedValue(undefined) };
|
|
636
|
+
const msg = mockMessage();
|
|
637
|
+
msg.reply = vi.fn().mockResolvedValue(replyObj);
|
|
638
|
+
const reaction = mockReaction({ message: msg });
|
|
639
|
+
await handler(reaction, mockUser());
|
|
640
|
+
expect(replyObj.delete).toHaveBeenCalledOnce();
|
|
641
|
+
// edit is called during streaming (placeholder update), but editThenSendChunks should NOT be reached.
|
|
642
|
+
// The log should confirm suppression.
|
|
643
|
+
expect(params.log?.info).toHaveBeenCalledWith(expect.objectContaining({ sessionKey: expect.any(String), chars: expect.any(Number) }), expect.stringContaining('trivial response suppressed'));
|
|
644
|
+
});
|
|
645
|
+
it('does not suppress genuine short responses (e.g. "ok")', async () => {
|
|
646
|
+
const shortResponse = 'ok';
|
|
647
|
+
const params = makeParams({ runtime: makeMockRuntime(shortResponse) });
|
|
648
|
+
const queue = mockQueue();
|
|
649
|
+
const handler = createReactionAddHandler(params, queue);
|
|
650
|
+
const replyObj = { edit: vi.fn().mockResolvedValue(undefined), delete: vi.fn().mockResolvedValue(undefined) };
|
|
651
|
+
const msg = mockMessage();
|
|
652
|
+
msg.reply = vi.fn().mockResolvedValue(replyObj);
|
|
653
|
+
const reaction = mockReaction({ message: msg });
|
|
654
|
+
await handler(reaction, mockUser());
|
|
655
|
+
expect(replyObj.delete).not.toHaveBeenCalled();
|
|
656
|
+
// editThenSendChunks calls reply.edit with the final text
|
|
657
|
+
expect(replyObj.edit).toHaveBeenCalledWith(expect.objectContaining({ content: expect.stringContaining(shortResponse) }));
|
|
658
|
+
});
|
|
659
|
+
it('does not suppress HEARTBEAT_OK when it has Discord actions', async () => {
|
|
660
|
+
const responseWithAction = 'HEARTBEAT_OK\n<discord-action>{"type":"react","channelId":"ch-1","messageId":"msg-1","emoji":"👍"}</discord-action>';
|
|
661
|
+
const params = makeParams({
|
|
662
|
+
runtime: makeMockRuntime(responseWithAction),
|
|
663
|
+
discordActionsEnabled: true,
|
|
664
|
+
discordActionsMessaging: true,
|
|
665
|
+
});
|
|
666
|
+
const queue = mockQueue();
|
|
667
|
+
const handler = createReactionAddHandler(params, queue);
|
|
668
|
+
const replyObj = { edit: vi.fn().mockResolvedValue(undefined), delete: vi.fn().mockResolvedValue(undefined) };
|
|
669
|
+
const msg = mockMessage();
|
|
670
|
+
msg.reply = vi.fn().mockResolvedValue(replyObj);
|
|
671
|
+
const reaction = mockReaction({ message: msg });
|
|
672
|
+
await handler(reaction, mockUser());
|
|
673
|
+
// Should NOT be suppressed because there are parsed actions.
|
|
674
|
+
expect(replyObj.delete).not.toHaveBeenCalled();
|
|
675
|
+
});
|
|
676
|
+
it('suppresses whitespace-only responses', async () => {
|
|
677
|
+
const params = makeParams({ runtime: makeMockRuntime(' \n ') });
|
|
678
|
+
const queue = mockQueue();
|
|
679
|
+
const handler = createReactionAddHandler(params, queue);
|
|
680
|
+
const replyObj = { edit: vi.fn().mockResolvedValue(undefined), delete: vi.fn().mockResolvedValue(undefined) };
|
|
681
|
+
const msg = mockMessage();
|
|
682
|
+
msg.reply = vi.fn().mockResolvedValue(replyObj);
|
|
683
|
+
const reaction = mockReaction({ message: msg });
|
|
684
|
+
await handler(reaction, mockUser());
|
|
685
|
+
expect(replyObj.delete).toHaveBeenCalledOnce();
|
|
686
|
+
});
|
|
687
|
+
it('does not suppress short responses that have images', async () => {
|
|
688
|
+
const runtime = {
|
|
689
|
+
id: 'claude_code',
|
|
690
|
+
capabilities: new Set(['streaming_text']),
|
|
691
|
+
async *invoke() {
|
|
692
|
+
yield { type: 'text_final', text: 'Here.' };
|
|
693
|
+
yield { type: 'image_data', image: { data: 'abc', mediaType: 'image/png' } };
|
|
694
|
+
yield { type: 'done' };
|
|
695
|
+
},
|
|
696
|
+
};
|
|
697
|
+
const params = makeParams({ runtime });
|
|
698
|
+
const queue = mockQueue();
|
|
699
|
+
const handler = createReactionAddHandler(params, queue);
|
|
700
|
+
const replyObj = { edit: vi.fn().mockResolvedValue(undefined), delete: vi.fn().mockResolvedValue(undefined) };
|
|
701
|
+
const msg = mockMessage();
|
|
702
|
+
msg.reply = vi.fn().mockResolvedValue(replyObj);
|
|
703
|
+
const reaction = mockReaction({ message: msg });
|
|
704
|
+
await handler(reaction, mockUser());
|
|
705
|
+
// Should NOT be suppressed because images are present.
|
|
706
|
+
expect(replyObj.delete).not.toHaveBeenCalled();
|
|
707
|
+
});
|
|
708
|
+
it('suppresses (no output) fallback response', async () => {
|
|
709
|
+
// When runtime produces empty text and no images, processedText becomes '(no output)' (11 chars).
|
|
710
|
+
const runtime = {
|
|
711
|
+
id: 'claude_code',
|
|
712
|
+
capabilities: new Set(['streaming_text']),
|
|
713
|
+
async *invoke() {
|
|
714
|
+
yield { type: 'text_final', text: '' };
|
|
715
|
+
yield { type: 'done' };
|
|
716
|
+
},
|
|
717
|
+
};
|
|
718
|
+
const params = makeParams({ runtime });
|
|
719
|
+
const queue = mockQueue();
|
|
720
|
+
const handler = createReactionAddHandler(params, queue);
|
|
721
|
+
const replyObj = { edit: vi.fn().mockResolvedValue(undefined), delete: vi.fn().mockResolvedValue(undefined) };
|
|
722
|
+
const msg = mockMessage();
|
|
723
|
+
msg.reply = vi.fn().mockResolvedValue(replyObj);
|
|
724
|
+
const reaction = mockReaction({ message: msg });
|
|
725
|
+
await handler(reaction, mockUser());
|
|
726
|
+
expect(replyObj.delete).toHaveBeenCalledOnce();
|
|
727
|
+
});
|
|
728
|
+
it('surfaces unavailable action types instead of suppressing empty output', async () => {
|
|
729
|
+
const runtime = {
|
|
730
|
+
id: 'claude_code',
|
|
731
|
+
capabilities: new Set(['streaming_text']),
|
|
732
|
+
async *invoke() {
|
|
733
|
+
yield { type: 'text_final', text: '<discord-action>{"type":"totallyUnknownAction"}</discord-action>' };
|
|
734
|
+
yield { type: 'done' };
|
|
735
|
+
},
|
|
736
|
+
};
|
|
737
|
+
const params = makeParams({
|
|
738
|
+
runtime,
|
|
739
|
+
discordActionsEnabled: true,
|
|
740
|
+
discordActionsChannels: true,
|
|
741
|
+
});
|
|
742
|
+
const queue = mockQueue();
|
|
743
|
+
const handler = createReactionAddHandler(params, queue);
|
|
744
|
+
const replyObj = { edit: vi.fn().mockResolvedValue(undefined), delete: vi.fn().mockResolvedValue(undefined) };
|
|
745
|
+
const msg = mockMessage();
|
|
746
|
+
msg.reply = vi.fn().mockResolvedValue(replyObj);
|
|
747
|
+
const reaction = mockReaction({ message: msg });
|
|
748
|
+
await handler(reaction, mockUser());
|
|
749
|
+
expect(replyObj.delete).not.toHaveBeenCalled();
|
|
750
|
+
const lastEdit = replyObj.edit.mock.calls[replyObj.edit.mock.calls.length - 1]?.[0]?.content ?? '';
|
|
751
|
+
expect(lastEdit).toContain('Ignored unavailable action type:');
|
|
752
|
+
expect(lastEdit).toContain('`totallyUnknownAction`');
|
|
753
|
+
});
|
|
754
|
+
it('dispose() is called even when suppression triggers early return', async () => {
|
|
755
|
+
const params = makeParams({ runtime: makeMockRuntime('HEARTBEAT_OK') });
|
|
756
|
+
const queue = mockQueue();
|
|
757
|
+
const handler = createReactionAddHandler(params, queue);
|
|
758
|
+
const replyObj = { edit: vi.fn().mockResolvedValue(undefined), delete: vi.fn().mockResolvedValue(undefined) };
|
|
759
|
+
const msg = mockMessage();
|
|
760
|
+
msg.reply = vi.fn().mockResolvedValue(replyObj);
|
|
761
|
+
const reaction = mockReaction({ message: msg });
|
|
762
|
+
await handler(reaction, mockUser());
|
|
763
|
+
// In-flight reply should be cleaned up (count back to 0).
|
|
764
|
+
expect(inFlightReplyCount()).toBe(0);
|
|
765
|
+
});
|
|
766
|
+
it('swallows 50083 (thread archived) without triggering handlerError', async () => {
|
|
767
|
+
const statusPoster = {
|
|
768
|
+
online: vi.fn(),
|
|
769
|
+
offline: vi.fn(),
|
|
770
|
+
runtimeError: vi.fn(),
|
|
771
|
+
handlerError: vi.fn(),
|
|
772
|
+
actionFailed: vi.fn(),
|
|
773
|
+
taskSyncComplete: vi.fn(),
|
|
774
|
+
};
|
|
775
|
+
const statusRef = { current: statusPoster };
|
|
776
|
+
const params = makeParams();
|
|
777
|
+
const queue = mockQueue();
|
|
778
|
+
const handler = createReactionAddHandler(params, queue, statusRef);
|
|
779
|
+
// Make reply.edit throw a Discord 50083 "Thread is archived" error.
|
|
780
|
+
const err50083 = Object.assign(new Error('Thread is archived'), { code: 50083 });
|
|
781
|
+
const replyObj = { edit: vi.fn().mockRejectedValue(err50083), delete: vi.fn().mockResolvedValue(undefined) };
|
|
782
|
+
const msg = mockMessage();
|
|
783
|
+
msg._replyObj = replyObj;
|
|
784
|
+
msg.reply = vi.fn().mockResolvedValue(replyObj);
|
|
785
|
+
const reaction = mockReaction({ message: msg });
|
|
786
|
+
await handler(reaction, mockUser());
|
|
787
|
+
expect(statusPoster.handlerError).not.toHaveBeenCalled();
|
|
788
|
+
expect(params.log?.info).toHaveBeenCalledWith(expect.objectContaining({ sessionKey: expect.any(String) }), expect.stringContaining('reply skipped (thread archived by action)'));
|
|
789
|
+
});
|
|
790
|
+
it('text file attachments are downloaded and inlined in the prompt', async () => {
|
|
791
|
+
const invokeSpy = vi.fn();
|
|
792
|
+
const runtime = {
|
|
793
|
+
id: 'claude_code',
|
|
794
|
+
capabilities: new Set(['streaming_text']),
|
|
795
|
+
async *invoke(p) {
|
|
796
|
+
invokeSpy(p);
|
|
797
|
+
yield { type: 'text_final', text: 'ok' };
|
|
798
|
+
yield { type: 'done' };
|
|
799
|
+
},
|
|
800
|
+
};
|
|
801
|
+
const params = makeParams({ runtime });
|
|
802
|
+
const queue = mockQueue();
|
|
803
|
+
const handler = createReactionAddHandler(params, queue);
|
|
804
|
+
const originalFetch = globalThis.fetch;
|
|
805
|
+
const fileContent = 'const x = 42;';
|
|
806
|
+
globalThis.fetch = vi.fn().mockResolvedValue({
|
|
807
|
+
ok: true,
|
|
808
|
+
arrayBuffer: () => Promise.resolve(new TextEncoder().encode(fileContent).buffer),
|
|
809
|
+
});
|
|
810
|
+
try {
|
|
811
|
+
const reaction = mockReaction();
|
|
812
|
+
reaction.message.attachments = {
|
|
813
|
+
size: 1,
|
|
814
|
+
values: () => [{
|
|
815
|
+
url: 'https://cdn.discordapp.com/attachments/123/456/example.ts',
|
|
816
|
+
name: 'example.ts',
|
|
817
|
+
contentType: null,
|
|
818
|
+
size: 100,
|
|
819
|
+
}],
|
|
820
|
+
};
|
|
821
|
+
await handler(reaction, mockUser());
|
|
822
|
+
const invokeParams = invokeSpy.mock.calls[0][0];
|
|
823
|
+
expect(invokeParams.prompt).toContain('[Attached file: example.ts]');
|
|
824
|
+
expect(invokeParams.prompt).toContain('const x = 42;');
|
|
825
|
+
expect(invokeParams.prompt).not.toContain('https://cdn.discordapp.com');
|
|
826
|
+
}
|
|
827
|
+
finally {
|
|
828
|
+
globalThis.fetch = originalFetch;
|
|
829
|
+
}
|
|
830
|
+
});
|
|
831
|
+
it('unsupported non-image attachment types produce notes in the prompt', async () => {
|
|
832
|
+
const invokeSpy = vi.fn();
|
|
833
|
+
const runtime = {
|
|
834
|
+
id: 'claude_code',
|
|
835
|
+
capabilities: new Set(['streaming_text']),
|
|
836
|
+
async *invoke(p) {
|
|
837
|
+
invokeSpy(p);
|
|
838
|
+
yield { type: 'text_final', text: 'ok' };
|
|
839
|
+
yield { type: 'done' };
|
|
840
|
+
},
|
|
841
|
+
};
|
|
842
|
+
const params = makeParams({ runtime });
|
|
843
|
+
const queue = mockQueue();
|
|
844
|
+
const handler = createReactionAddHandler(params, queue);
|
|
845
|
+
const originalFetch = globalThis.fetch;
|
|
846
|
+
globalThis.fetch = vi.fn().mockRejectedValue(new Error('should not be called'));
|
|
847
|
+
try {
|
|
848
|
+
const reaction = mockReaction();
|
|
849
|
+
reaction.message.attachments = {
|
|
850
|
+
size: 1,
|
|
851
|
+
values: () => [{
|
|
852
|
+
url: 'https://cdn.discordapp.com/attachments/123/456/archive.zip',
|
|
853
|
+
name: 'archive.zip',
|
|
854
|
+
contentType: 'application/zip',
|
|
855
|
+
size: 5000,
|
|
856
|
+
}],
|
|
857
|
+
};
|
|
858
|
+
await handler(reaction, mockUser());
|
|
859
|
+
const invokeParams = invokeSpy.mock.calls[0][0];
|
|
860
|
+
expect(invokeParams.prompt).toContain('[Unsupported attachment: archive.zip (application/zip)]');
|
|
861
|
+
expect(invokeParams.prompt).not.toContain('https://cdn.discordapp.com');
|
|
862
|
+
// fetch should NOT have been called (unsupported type is classified before download)
|
|
863
|
+
expect(globalThis.fetch).not.toHaveBeenCalled();
|
|
864
|
+
}
|
|
865
|
+
finally {
|
|
866
|
+
globalThis.fetch = originalFetch;
|
|
867
|
+
}
|
|
868
|
+
});
|
|
869
|
+
it('mixed image and text attachments: images passed as ImageData, text inlined in prompt', async () => {
|
|
870
|
+
const invokeSpy = vi.fn();
|
|
871
|
+
const runtime = {
|
|
872
|
+
id: 'claude_code',
|
|
873
|
+
capabilities: new Set(['streaming_text']),
|
|
874
|
+
async *invoke(p) {
|
|
875
|
+
invokeSpy(p);
|
|
876
|
+
yield { type: 'text_final', text: 'ok' };
|
|
877
|
+
yield { type: 'done' };
|
|
878
|
+
},
|
|
879
|
+
};
|
|
880
|
+
const params = makeParams({ runtime });
|
|
881
|
+
const queue = mockQueue();
|
|
882
|
+
const handler = createReactionAddHandler(params, queue);
|
|
883
|
+
const originalFetch = globalThis.fetch;
|
|
884
|
+
const fileContent = 'hello = true';
|
|
885
|
+
const imgData = (() => { const b = Buffer.alloc(45); Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]).copy(b); return b; })();
|
|
886
|
+
globalThis.fetch = vi.fn().mockImplementation((url) => {
|
|
887
|
+
if (url.includes('.toml')) {
|
|
888
|
+
return Promise.resolve({
|
|
889
|
+
ok: true,
|
|
890
|
+
arrayBuffer: () => Promise.resolve(new TextEncoder().encode(fileContent).buffer),
|
|
891
|
+
});
|
|
892
|
+
}
|
|
893
|
+
// Image path: downloadAttachment does buffer.toString('base64') with no content validation
|
|
894
|
+
return Promise.resolve({
|
|
895
|
+
ok: true,
|
|
896
|
+
arrayBuffer: () => Promise.resolve(imgData.buffer.slice(imgData.byteOffset, imgData.byteOffset + imgData.byteLength)),
|
|
897
|
+
});
|
|
898
|
+
});
|
|
899
|
+
try {
|
|
900
|
+
const reaction = mockReaction();
|
|
901
|
+
reaction.message.attachments = {
|
|
902
|
+
size: 2,
|
|
903
|
+
values: () => [
|
|
904
|
+
{
|
|
905
|
+
url: 'https://cdn.discordapp.com/attachments/123/456/photo.png',
|
|
906
|
+
name: 'photo.png',
|
|
907
|
+
contentType: 'image/png',
|
|
908
|
+
size: 100,
|
|
909
|
+
},
|
|
910
|
+
{
|
|
911
|
+
url: 'https://cdn.discordapp.com/attachments/123/456/config.toml',
|
|
912
|
+
name: 'config.toml',
|
|
913
|
+
contentType: null,
|
|
914
|
+
size: 50,
|
|
915
|
+
},
|
|
916
|
+
],
|
|
917
|
+
};
|
|
918
|
+
await handler(reaction, mockUser());
|
|
919
|
+
const invokeParams = invokeSpy.mock.calls[0][0];
|
|
920
|
+
// Image should be in images array
|
|
921
|
+
expect(invokeParams.images).toBeDefined();
|
|
922
|
+
expect(invokeParams.images).toHaveLength(1);
|
|
923
|
+
expect(invokeParams.images[0].mediaType).toBe('image/png');
|
|
924
|
+
// Text file should be inlined in prompt
|
|
925
|
+
expect(invokeParams.prompt).toContain('[Attached file: config.toml]');
|
|
926
|
+
expect(invokeParams.prompt).toContain('hello = true');
|
|
927
|
+
}
|
|
928
|
+
finally {
|
|
929
|
+
globalThis.fetch = originalFetch;
|
|
930
|
+
}
|
|
931
|
+
});
|
|
932
|
+
it('text attachment download failure is caught and handler continues', async () => {
|
|
933
|
+
const invokeSpy = vi.fn();
|
|
934
|
+
const runtime = {
|
|
935
|
+
id: 'claude_code',
|
|
936
|
+
capabilities: new Set(['streaming_text']),
|
|
937
|
+
async *invoke(p) {
|
|
938
|
+
invokeSpy(p);
|
|
939
|
+
yield { type: 'text_final', text: 'ok' };
|
|
940
|
+
yield { type: 'done' };
|
|
941
|
+
},
|
|
942
|
+
};
|
|
943
|
+
const params = makeParams({ runtime });
|
|
944
|
+
const queue = mockQueue();
|
|
945
|
+
const handler = createReactionAddHandler(params, queue);
|
|
946
|
+
// Mock fetch to throw — downloadTextAttachments catches per-file errors internally
|
|
947
|
+
// and surfaces them as textResult.errors entries rather than throwing
|
|
948
|
+
const originalFetch = globalThis.fetch;
|
|
949
|
+
globalThis.fetch = vi.fn().mockRejectedValue(new Error('network collapsed'));
|
|
950
|
+
try {
|
|
951
|
+
const reaction = mockReaction();
|
|
952
|
+
reaction.message.attachments = {
|
|
953
|
+
size: 1,
|
|
954
|
+
values: () => [{
|
|
955
|
+
url: 'https://cdn.discordapp.com/attachments/123/456/example.ts',
|
|
956
|
+
name: 'example.ts',
|
|
957
|
+
contentType: null,
|
|
958
|
+
size: 100,
|
|
959
|
+
}],
|
|
960
|
+
};
|
|
961
|
+
await handler(reaction, mockUser());
|
|
962
|
+
// Handler should still invoke the runtime (graceful degradation)
|
|
963
|
+
expect(invokeSpy).toHaveBeenCalledOnce();
|
|
964
|
+
const invokeParams = invokeSpy.mock.calls[0][0];
|
|
965
|
+
// File contents should not be present (download failed)
|
|
966
|
+
expect(invokeParams.prompt).not.toContain('[Attached file:');
|
|
967
|
+
// The error is caught per-file inside downloadTextAttachments, so it surfaces
|
|
968
|
+
// as a textResult.errors entry logged via info, not the outer catch's warn
|
|
969
|
+
expect(invokeParams.prompt).toContain('example.ts: download failed');
|
|
970
|
+
}
|
|
971
|
+
finally {
|
|
972
|
+
globalThis.fetch = originalFetch;
|
|
973
|
+
}
|
|
974
|
+
});
|
|
975
|
+
it('still triggers handlerError for non-50083 Discord errors', async () => {
|
|
976
|
+
const statusPoster = {
|
|
977
|
+
online: vi.fn(),
|
|
978
|
+
offline: vi.fn(),
|
|
979
|
+
runtimeError: vi.fn(),
|
|
980
|
+
handlerError: vi.fn(),
|
|
981
|
+
actionFailed: vi.fn(),
|
|
982
|
+
taskSyncComplete: vi.fn(),
|
|
983
|
+
};
|
|
984
|
+
const statusRef = { current: statusPoster };
|
|
985
|
+
const params = makeParams();
|
|
986
|
+
const queue = mockQueue();
|
|
987
|
+
const handler = createReactionAddHandler(params, queue, statusRef);
|
|
988
|
+
const err50013 = Object.assign(new Error('Missing Permissions'), { code: 50013 });
|
|
989
|
+
const replyObj = { edit: vi.fn().mockRejectedValue(err50013), delete: vi.fn().mockResolvedValue(undefined) };
|
|
990
|
+
const msg = mockMessage();
|
|
991
|
+
msg._replyObj = replyObj;
|
|
992
|
+
msg.reply = vi.fn().mockResolvedValue(replyObj);
|
|
993
|
+
const reaction = mockReaction({ message: msg });
|
|
994
|
+
await handler(reaction, mockUser());
|
|
995
|
+
expect(statusPoster.handlerError).toHaveBeenCalledOnce();
|
|
996
|
+
});
|
|
997
|
+
});
|
|
998
|
+
describe('createReactionRemoveHandler', () => {
|
|
999
|
+
it('ignores self-reactions (bot reacting to its own)', async () => {
|
|
1000
|
+
const params = makeParams();
|
|
1001
|
+
const queue = mockQueue();
|
|
1002
|
+
const handler = createReactionRemoveHandler(params, queue);
|
|
1003
|
+
const reaction = mockReaction();
|
|
1004
|
+
const user = mockUser({ id: 'bot-1' });
|
|
1005
|
+
await handler(reaction, user);
|
|
1006
|
+
expect(queue.run).not.toHaveBeenCalled();
|
|
1007
|
+
});
|
|
1008
|
+
it('ignores non-allowlisted users', async () => {
|
|
1009
|
+
const params = makeParams({ allowUserIds: new Set(['other-user']) });
|
|
1010
|
+
const queue = mockQueue();
|
|
1011
|
+
const handler = createReactionRemoveHandler(params, queue);
|
|
1012
|
+
await handler(mockReaction(), mockUser());
|
|
1013
|
+
expect(queue.run).not.toHaveBeenCalled();
|
|
1014
|
+
});
|
|
1015
|
+
it('ignores reactions in non-allowed channels', async () => {
|
|
1016
|
+
const params = makeParams({ allowChannelIds: new Set(['other-channel']) });
|
|
1017
|
+
const queue = mockQueue();
|
|
1018
|
+
const handler = createReactionRemoveHandler(params, queue);
|
|
1019
|
+
await handler(mockReaction(), mockUser());
|
|
1020
|
+
expect(queue.run).not.toHaveBeenCalled();
|
|
1021
|
+
});
|
|
1022
|
+
it('ignores DM reactions (guildId null)', async () => {
|
|
1023
|
+
const params = makeParams();
|
|
1024
|
+
const queue = mockQueue();
|
|
1025
|
+
const handler = createReactionRemoveHandler(params, queue);
|
|
1026
|
+
const reaction = mockReaction({
|
|
1027
|
+
message: mockMessage({ guildId: null }),
|
|
1028
|
+
});
|
|
1029
|
+
await handler(reaction, mockUser());
|
|
1030
|
+
expect(queue.run).not.toHaveBeenCalled();
|
|
1031
|
+
});
|
|
1032
|
+
it('ignores stale messages older than reactionMaxAgeMs', async () => {
|
|
1033
|
+
const params = makeParams({ reactionMaxAgeMs: 1000 });
|
|
1034
|
+
const queue = mockQueue();
|
|
1035
|
+
const handler = createReactionRemoveHandler(params, queue);
|
|
1036
|
+
const reaction = mockReaction({
|
|
1037
|
+
message: mockMessage({ createdTimestamp: Date.now() - 5000 }),
|
|
1038
|
+
});
|
|
1039
|
+
await handler(reaction, mockUser());
|
|
1040
|
+
expect(queue.run).not.toHaveBeenCalled();
|
|
1041
|
+
});
|
|
1042
|
+
it('happy path — allowlisted user unreacts, runtime responds, reply posted', async () => {
|
|
1043
|
+
const params = makeParams();
|
|
1044
|
+
const queue = mockQueue();
|
|
1045
|
+
const handler = createReactionRemoveHandler(params, queue);
|
|
1046
|
+
const reaction = mockReaction();
|
|
1047
|
+
await handler(reaction, mockUser());
|
|
1048
|
+
expect(queue.run).toHaveBeenCalledOnce();
|
|
1049
|
+
// Immediate placeholder reply.
|
|
1050
|
+
expect(reaction.message.reply).toHaveBeenCalledOnce();
|
|
1051
|
+
expect(reaction.message.reply.mock.calls[0][0].content).toMatch(/Thinking/);
|
|
1052
|
+
// Final output via edit on the reply object.
|
|
1053
|
+
const replyObj = reaction.message._replyObj;
|
|
1054
|
+
const lastEditCall = replyObj.edit.mock.calls[replyObj.edit.mock.calls.length - 1];
|
|
1055
|
+
expect(lastEditCall[0].content).toContain('Reaction response!');
|
|
1056
|
+
});
|
|
1057
|
+
it('prompt contains "removed their" and does NOT contain "reacted with"', async () => {
|
|
1058
|
+
const invokeSpy = vi.fn();
|
|
1059
|
+
const runtime = {
|
|
1060
|
+
id: 'claude_code',
|
|
1061
|
+
capabilities: new Set(['streaming_text']),
|
|
1062
|
+
async *invoke(p) {
|
|
1063
|
+
invokeSpy(p);
|
|
1064
|
+
yield { type: 'text_final', text: 'ok' };
|
|
1065
|
+
yield { type: 'done' };
|
|
1066
|
+
},
|
|
1067
|
+
};
|
|
1068
|
+
const params = makeParams({ runtime });
|
|
1069
|
+
const queue = mockQueue();
|
|
1070
|
+
const handler = createReactionRemoveHandler(params, queue);
|
|
1071
|
+
await handler(mockReaction(), mockUser());
|
|
1072
|
+
expect(invokeSpy).toHaveBeenCalledOnce();
|
|
1073
|
+
const prompt = invokeSpy.mock.calls[0][0].prompt;
|
|
1074
|
+
expect(prompt).toContain('removed their');
|
|
1075
|
+
expect(prompt).not.toContain('reacted with');
|
|
1076
|
+
});
|
|
1077
|
+
it('increments discord.reaction_remove.received metric', async () => {
|
|
1078
|
+
const { MetricsRegistry } = await import('../observability/metrics.js');
|
|
1079
|
+
const metrics = new MetricsRegistry();
|
|
1080
|
+
const params = makeParams({ metrics });
|
|
1081
|
+
const queue = mockQueue();
|
|
1082
|
+
const handler = createReactionRemoveHandler(params, queue);
|
|
1083
|
+
await handler(mockReaction(), mockUser());
|
|
1084
|
+
const snap = metrics.snapshot();
|
|
1085
|
+
expect(snap.counters['discord.reaction_remove.received']).toBe(1);
|
|
1086
|
+
});
|
|
1087
|
+
it('handles partial reaction fetch failure gracefully', async () => {
|
|
1088
|
+
const params = makeParams();
|
|
1089
|
+
const queue = mockQueue();
|
|
1090
|
+
const handler = createReactionRemoveHandler(params, queue);
|
|
1091
|
+
const reaction = mockReaction({
|
|
1092
|
+
partial: true,
|
|
1093
|
+
fetch: vi.fn().mockRejectedValue(new Error('Unknown Reaction')),
|
|
1094
|
+
});
|
|
1095
|
+
await handler(reaction, mockUser());
|
|
1096
|
+
expect(params.log?.warn).toHaveBeenCalled();
|
|
1097
|
+
expect(queue.run).not.toHaveBeenCalled();
|
|
1098
|
+
});
|
|
1099
|
+
it('handles partial message fetch failure gracefully', async () => {
|
|
1100
|
+
const params = makeParams();
|
|
1101
|
+
const queue = mockQueue();
|
|
1102
|
+
const handler = createReactionRemoveHandler(params, queue);
|
|
1103
|
+
const msg = mockMessage({
|
|
1104
|
+
partial: true,
|
|
1105
|
+
fetch: vi.fn().mockRejectedValue(new Error('Unknown Message')),
|
|
1106
|
+
});
|
|
1107
|
+
const reaction = mockReaction({ message: msg });
|
|
1108
|
+
await handler(reaction, mockUser());
|
|
1109
|
+
expect(params.log?.warn).toHaveBeenCalled();
|
|
1110
|
+
expect(queue.run).not.toHaveBeenCalled();
|
|
1111
|
+
});
|
|
1112
|
+
it('handles runtime error (logged, status posted)', async () => {
|
|
1113
|
+
const statusPoster = {
|
|
1114
|
+
online: vi.fn(),
|
|
1115
|
+
offline: vi.fn(),
|
|
1116
|
+
runtimeError: vi.fn(),
|
|
1117
|
+
handlerError: vi.fn(),
|
|
1118
|
+
actionFailed: vi.fn(),
|
|
1119
|
+
taskSyncComplete: vi.fn(),
|
|
1120
|
+
};
|
|
1121
|
+
const statusRef = { current: statusPoster };
|
|
1122
|
+
const params = makeParams({ runtime: makeMockRuntimeError('timeout reached') });
|
|
1123
|
+
const queue = mockQueue();
|
|
1124
|
+
const handler = createReactionRemoveHandler(params, queue, statusRef);
|
|
1125
|
+
const reaction = mockReaction();
|
|
1126
|
+
await handler(reaction, mockUser());
|
|
1127
|
+
expect(params.log?.error).toHaveBeenCalled();
|
|
1128
|
+
expect(statusPoster.runtimeError).toHaveBeenCalledOnce();
|
|
1129
|
+
// Placeholder first, then error via edit.
|
|
1130
|
+
expect(reaction.message.reply).toHaveBeenCalledOnce();
|
|
1131
|
+
expect(reaction.message.reply.mock.calls[0][0].content).toMatch(/Thinking/);
|
|
1132
|
+
const replyObj = reaction.message._replyObj;
|
|
1133
|
+
const lastEditCall = replyObj.edit.mock.calls[replyObj.edit.mock.calls.length - 1];
|
|
1134
|
+
expect(lastEditCall[0].content).toContain('Runtime error: timeout reached');
|
|
1135
|
+
});
|
|
1136
|
+
});
|
|
1137
|
+
describe('streaming behavior', () => {
|
|
1138
|
+
it('emits multiple edits for text_delta events (throttled)', async () => {
|
|
1139
|
+
vi.useFakeTimers();
|
|
1140
|
+
try {
|
|
1141
|
+
const runtime = {
|
|
1142
|
+
id: 'claude_code',
|
|
1143
|
+
capabilities: new Set(['streaming_text']),
|
|
1144
|
+
async *invoke() {
|
|
1145
|
+
yield { type: 'text_delta', text: 'Hello ' };
|
|
1146
|
+
yield { type: 'text_delta', text: 'world' };
|
|
1147
|
+
yield { type: 'text_final', text: 'Hello world' };
|
|
1148
|
+
yield { type: 'done' };
|
|
1149
|
+
},
|
|
1150
|
+
};
|
|
1151
|
+
const params = makeParams({ runtime });
|
|
1152
|
+
const queue = mockQueue();
|
|
1153
|
+
const handler = createReactionAddHandler(params, queue);
|
|
1154
|
+
const reaction = mockReaction();
|
|
1155
|
+
await handler(reaction, mockUser());
|
|
1156
|
+
const replyObj = reaction.message._replyObj;
|
|
1157
|
+
// At least the forced final edit should have happened.
|
|
1158
|
+
expect(replyObj.edit).toHaveBeenCalled();
|
|
1159
|
+
// Last edit should contain final text.
|
|
1160
|
+
const lastCall = replyObj.edit.mock.calls[replyObj.edit.mock.calls.length - 1];
|
|
1161
|
+
expect(lastCall[0].content).toContain('Hello world');
|
|
1162
|
+
}
|
|
1163
|
+
finally {
|
|
1164
|
+
vi.useRealTimers();
|
|
1165
|
+
}
|
|
1166
|
+
});
|
|
1167
|
+
it('streams log_line events into delta text', async () => {
|
|
1168
|
+
const runtime = {
|
|
1169
|
+
id: 'claude_code',
|
|
1170
|
+
capabilities: new Set(['streaming_text']),
|
|
1171
|
+
async *invoke() {
|
|
1172
|
+
yield { type: 'log_line', stream: 'stdout', line: 'building...' };
|
|
1173
|
+
yield { type: 'log_line', stream: 'stderr', line: 'warn: deprecated' };
|
|
1174
|
+
yield { type: 'text_final', text: 'Done' };
|
|
1175
|
+
yield { type: 'done' };
|
|
1176
|
+
},
|
|
1177
|
+
};
|
|
1178
|
+
const params = makeParams({ runtime });
|
|
1179
|
+
const queue = mockQueue();
|
|
1180
|
+
const handler = createReactionAddHandler(params, queue);
|
|
1181
|
+
const reaction = mockReaction();
|
|
1182
|
+
await handler(reaction, mockUser());
|
|
1183
|
+
const replyObj = reaction.message._replyObj;
|
|
1184
|
+
// Some intermediate edit should contain the log lines.
|
|
1185
|
+
const allEditContents = replyObj.edit.mock.calls.map((c) => c[0].content);
|
|
1186
|
+
const hasStdout = allEditContents.some((c) => c.includes('[stdout]'));
|
|
1187
|
+
const hasStderr = allEditContents.some((c) => c.includes('[stderr]'));
|
|
1188
|
+
expect(hasStdout).toBe(true);
|
|
1189
|
+
expect(hasStderr).toBe(true);
|
|
1190
|
+
});
|
|
1191
|
+
it('waits for pending keepalive streaming edits before finalizing', async () => {
|
|
1192
|
+
vi.useFakeTimers();
|
|
1193
|
+
try {
|
|
1194
|
+
const unblockRuntime = (() => {
|
|
1195
|
+
let resolve;
|
|
1196
|
+
const promise = new Promise((r) => { resolve = r; });
|
|
1197
|
+
return { promise, resolve };
|
|
1198
|
+
})();
|
|
1199
|
+
const unblockFirstStallEdit = (() => {
|
|
1200
|
+
let resolve;
|
|
1201
|
+
const promise = new Promise((r) => { resolve = r; });
|
|
1202
|
+
return { promise, resolve };
|
|
1203
|
+
})();
|
|
1204
|
+
const runtimeStarted = (() => {
|
|
1205
|
+
let resolve;
|
|
1206
|
+
const promise = new Promise((r) => { resolve = r; });
|
|
1207
|
+
return { promise, resolve };
|
|
1208
|
+
})();
|
|
1209
|
+
let stallEditCalls = 0;
|
|
1210
|
+
const runtime = {
|
|
1211
|
+
id: 'claude_code',
|
|
1212
|
+
capabilities: new Set(['streaming_text']),
|
|
1213
|
+
async *invoke() {
|
|
1214
|
+
runtimeStarted.resolve();
|
|
1215
|
+
await unblockRuntime.promise;
|
|
1216
|
+
yield { type: 'text_final', text: 'Final answer' };
|
|
1217
|
+
yield { type: 'done' };
|
|
1218
|
+
},
|
|
1219
|
+
};
|
|
1220
|
+
const replyObj = {
|
|
1221
|
+
edit: vi.fn().mockImplementation(async () => {
|
|
1222
|
+
stallEditCalls++;
|
|
1223
|
+
if (stallEditCalls === 1) {
|
|
1224
|
+
await unblockFirstStallEdit.promise;
|
|
1225
|
+
}
|
|
1226
|
+
}),
|
|
1227
|
+
delete: vi.fn().mockResolvedValue(undefined),
|
|
1228
|
+
};
|
|
1229
|
+
const reaction = mockReaction({
|
|
1230
|
+
message: mockMessage({
|
|
1231
|
+
reply: vi.fn().mockResolvedValue(replyObj),
|
|
1232
|
+
_replyObj: replyObj,
|
|
1233
|
+
}),
|
|
1234
|
+
});
|
|
1235
|
+
const params = makeParams({ runtime, streamStallWarningMs: 1 });
|
|
1236
|
+
const queue = mockQueue();
|
|
1237
|
+
const handler = createReactionAddHandler(params, queue);
|
|
1238
|
+
let settled = false;
|
|
1239
|
+
const pending = handler(reaction, mockUser()).then(() => { settled = true; });
|
|
1240
|
+
await runtimeStarted.promise;
|
|
1241
|
+
await vi.advanceTimersByTimeAsync(5000);
|
|
1242
|
+
expect(stallEditCalls).toBe(1);
|
|
1243
|
+
unblockRuntime.resolve();
|
|
1244
|
+
await Promise.resolve();
|
|
1245
|
+
await Promise.resolve();
|
|
1246
|
+
expect(settled).toBe(false);
|
|
1247
|
+
expect(replyObj.edit.mock.calls.some((call) => String(call?.[0]?.content ?? '').includes('Final answer'))).toBe(false);
|
|
1248
|
+
unblockFirstStallEdit.resolve();
|
|
1249
|
+
await pending;
|
|
1250
|
+
expect(replyObj.edit.mock.calls.some((call) => String(call?.[0]?.content ?? '').includes('Final answer'))).toBe(true);
|
|
1251
|
+
}
|
|
1252
|
+
finally {
|
|
1253
|
+
vi.useRealTimers();
|
|
1254
|
+
}
|
|
1255
|
+
});
|
|
1256
|
+
it('cleans up stale placeholder on handler error after reply was created', async () => {
|
|
1257
|
+
// Runtime that throws after yielding nothing.
|
|
1258
|
+
const runtime = {
|
|
1259
|
+
id: 'claude_code',
|
|
1260
|
+
capabilities: new Set(['streaming_text']),
|
|
1261
|
+
async *invoke() {
|
|
1262
|
+
throw new Error('unexpected crash');
|
|
1263
|
+
},
|
|
1264
|
+
};
|
|
1265
|
+
const params = makeParams({ runtime });
|
|
1266
|
+
const queue = mockQueue();
|
|
1267
|
+
const handler = createReactionAddHandler(params, queue);
|
|
1268
|
+
const reaction = mockReaction();
|
|
1269
|
+
await handler(reaction, mockUser());
|
|
1270
|
+
// Placeholder was posted.
|
|
1271
|
+
expect(reaction.message.reply).toHaveBeenCalledOnce();
|
|
1272
|
+
// Reply should be edited with error message (not left as "Thinking.").
|
|
1273
|
+
const replyObj = reaction.message._replyObj;
|
|
1274
|
+
expect(replyObj.edit).toHaveBeenCalled();
|
|
1275
|
+
const lastCall = replyObj.edit.mock.calls[replyObj.edit.mock.calls.length - 1];
|
|
1276
|
+
expect(lastCall[0].content).toMatch(/error|unexpected/i);
|
|
1277
|
+
});
|
|
1278
|
+
});
|
|
1279
|
+
describe('reaction prompt interception', () => {
|
|
1280
|
+
afterEach(() => {
|
|
1281
|
+
reactionPrompts._resetForTest();
|
|
1282
|
+
});
|
|
1283
|
+
it('continues into AI invocation with resolved-prompt text when reaction matches a pending prompt', async () => {
|
|
1284
|
+
const invokeSpy = vi.fn();
|
|
1285
|
+
const runtime = {
|
|
1286
|
+
id: 'claude_code',
|
|
1287
|
+
capabilities: new Set(['streaming_text']),
|
|
1288
|
+
async *invoke(p) {
|
|
1289
|
+
invokeSpy(p);
|
|
1290
|
+
yield { type: 'text_final', text: 'ok' };
|
|
1291
|
+
yield { type: 'done' };
|
|
1292
|
+
},
|
|
1293
|
+
};
|
|
1294
|
+
const spy = vi.spyOn(reactionPrompts, 'tryResolveReactionPrompt').mockReturnValue({ question: 'Proceed?', chosenEmoji: '✅' });
|
|
1295
|
+
try {
|
|
1296
|
+
const params = makeParams({ runtime });
|
|
1297
|
+
const queue = mockQueue();
|
|
1298
|
+
const handler = createReactionAddHandler(params, queue);
|
|
1299
|
+
await handler(mockReaction(), mockUser());
|
|
1300
|
+
expect(spy).toHaveBeenCalledWith('msg-1', expect.any(String));
|
|
1301
|
+
expect(queue.run).toHaveBeenCalledOnce();
|
|
1302
|
+
expect(invokeSpy).toHaveBeenCalledOnce();
|
|
1303
|
+
const prompt = invokeSpy.mock.calls[0][0].prompt;
|
|
1304
|
+
expect(prompt).toContain('✅');
|
|
1305
|
+
expect(prompt).toContain('Proceed?');
|
|
1306
|
+
expect(prompt).toContain('Act on the user\'s choice. Do not re-ask the question.');
|
|
1307
|
+
}
|
|
1308
|
+
finally {
|
|
1309
|
+
spy.mockRestore();
|
|
1310
|
+
}
|
|
1311
|
+
});
|
|
1312
|
+
it('prompt interception fires before staleness guard — resolves even when message is stale', async () => {
|
|
1313
|
+
const spy = vi.spyOn(reactionPrompts, 'tryResolveReactionPrompt').mockReturnValue({ question: 'test prompt', chosenEmoji: '✅' });
|
|
1314
|
+
try {
|
|
1315
|
+
const params = makeParams({ reactionMaxAgeMs: 1 }); // extremely short — would normally reject
|
|
1316
|
+
const queue = mockQueue();
|
|
1317
|
+
const handler = createReactionAddHandler(params, queue);
|
|
1318
|
+
// Message is "old" and would be rejected by the staleness guard if it fired first.
|
|
1319
|
+
const reaction = mockReaction({
|
|
1320
|
+
message: mockMessage({ createdTimestamp: Date.now() - 5000 }),
|
|
1321
|
+
});
|
|
1322
|
+
await handler(reaction, mockUser());
|
|
1323
|
+
// spy was called, proving interception ran before the staleness guard could short-circuit.
|
|
1324
|
+
expect(spy).toHaveBeenCalled();
|
|
1325
|
+
// Staleness guard is bypassed for resolved prompts, so the queue IS called.
|
|
1326
|
+
expect(queue.run).toHaveBeenCalledOnce();
|
|
1327
|
+
}
|
|
1328
|
+
finally {
|
|
1329
|
+
spy.mockRestore();
|
|
1330
|
+
}
|
|
1331
|
+
});
|
|
1332
|
+
it('passes full <:name:id> identifier for custom emoji to tryResolveReactionPrompt', async () => {
|
|
1333
|
+
const spy = vi.spyOn(reactionPrompts, 'tryResolveReactionPrompt').mockReturnValue({ question: 'test prompt', chosenEmoji: '✅' });
|
|
1334
|
+
try {
|
|
1335
|
+
const params = makeParams();
|
|
1336
|
+
const queue = mockQueue();
|
|
1337
|
+
const handler = createReactionAddHandler(params, queue);
|
|
1338
|
+
const reaction = mockReaction({
|
|
1339
|
+
emoji: { name: 'yes', id: '123456789' },
|
|
1340
|
+
});
|
|
1341
|
+
await handler(reaction, mockUser());
|
|
1342
|
+
expect(spy).toHaveBeenCalledWith('msg-1', '<:yes:123456789>');
|
|
1343
|
+
}
|
|
1344
|
+
finally {
|
|
1345
|
+
spy.mockRestore();
|
|
1346
|
+
}
|
|
1347
|
+
});
|
|
1348
|
+
it('unicode emoji passes name directly to tryResolveReactionPrompt', async () => {
|
|
1349
|
+
const spy = vi.spyOn(reactionPrompts, 'tryResolveReactionPrompt').mockReturnValue({ question: 'test prompt', chosenEmoji: '✅' });
|
|
1350
|
+
try {
|
|
1351
|
+
const params = makeParams();
|
|
1352
|
+
const queue = mockQueue();
|
|
1353
|
+
const handler = createReactionAddHandler(params, queue);
|
|
1354
|
+
const reaction = mockReaction({ emoji: { name: '✅' } });
|
|
1355
|
+
await handler(reaction, mockUser());
|
|
1356
|
+
expect(spy).toHaveBeenCalledWith('msg-1', '✅');
|
|
1357
|
+
// Resolved prompt continues into AI invocation.
|
|
1358
|
+
expect(queue.run).toHaveBeenCalledOnce();
|
|
1359
|
+
}
|
|
1360
|
+
finally {
|
|
1361
|
+
spy.mockRestore();
|
|
1362
|
+
}
|
|
1363
|
+
});
|
|
1364
|
+
it('remove handler does not call tryResolveReactionPrompt', async () => {
|
|
1365
|
+
const spy = vi.spyOn(reactionPrompts, 'tryResolveReactionPrompt');
|
|
1366
|
+
try {
|
|
1367
|
+
const params = makeParams();
|
|
1368
|
+
const queue = mockQueue();
|
|
1369
|
+
const handler = createReactionRemoveHandler(params, queue);
|
|
1370
|
+
await handler(mockReaction(), mockUser());
|
|
1371
|
+
expect(spy).not.toHaveBeenCalled();
|
|
1372
|
+
}
|
|
1373
|
+
finally {
|
|
1374
|
+
spy.mockRestore();
|
|
1375
|
+
}
|
|
1376
|
+
});
|
|
1377
|
+
it('reactionPrompt action → executor returns immediately → no auto-follow-up (not a query action)', async () => {
|
|
1378
|
+
const invokeCalls = [];
|
|
1379
|
+
const promptMsgId = 'prompt-integration-1';
|
|
1380
|
+
const reactFn = vi.fn().mockResolvedValue(undefined);
|
|
1381
|
+
const sendFn = vi.fn().mockResolvedValue({ id: promptMsgId, react: reactFn });
|
|
1382
|
+
const runtime = {
|
|
1383
|
+
id: 'claude_code',
|
|
1384
|
+
capabilities: new Set(['streaming_text']),
|
|
1385
|
+
async *invoke(p) {
|
|
1386
|
+
invokeCalls.push(p);
|
|
1387
|
+
// Emit a reactionPrompt action — fire-and-forget, not a query action.
|
|
1388
|
+
yield { type: 'text_final', text: '<discord-action>{"type":"reactionPrompt","question":"Proceed?","choices":["✅","❌"]}</discord-action>' };
|
|
1389
|
+
yield { type: 'done' };
|
|
1390
|
+
},
|
|
1391
|
+
};
|
|
1392
|
+
const textCh = { id: 'ch-1', name: 'general', send: sendFn };
|
|
1393
|
+
const params = makeParams({
|
|
1394
|
+
runtime,
|
|
1395
|
+
discordActionsEnabled: true,
|
|
1396
|
+
discordActionsMessaging: true,
|
|
1397
|
+
actionFollowupDepth: 1,
|
|
1398
|
+
});
|
|
1399
|
+
const reaction = mockReaction({
|
|
1400
|
+
message: mockMessage({
|
|
1401
|
+
guild: {
|
|
1402
|
+
channels: {
|
|
1403
|
+
cache: {
|
|
1404
|
+
get: (id) => id === 'ch-1' ? textCh : undefined,
|
|
1405
|
+
find: vi.fn(),
|
|
1406
|
+
},
|
|
1407
|
+
},
|
|
1408
|
+
},
|
|
1409
|
+
}),
|
|
1410
|
+
});
|
|
1411
|
+
const queue = mockQueue();
|
|
1412
|
+
const handler = createReactionAddHandler(params, queue);
|
|
1413
|
+
await handler(reaction, mockUser());
|
|
1414
|
+
// reactionPrompt is not in QUERY_ACTION_TYPES, so the auto-follow-up loop does not fire.
|
|
1415
|
+
// The second invocation (acting on the user's choice) happens in a separate handler call
|
|
1416
|
+
// triggered when the user actually reacts to the prompt message.
|
|
1417
|
+
expect(invokeCalls).toHaveLength(1);
|
|
1418
|
+
// The prompt was registered — the reaction handler will intercept the user's reaction.
|
|
1419
|
+
expect(reactionPrompts.pendingPromptCount()).toBe(1);
|
|
1420
|
+
});
|
|
1421
|
+
});
|
|
1422
|
+
describe('🛑 forge-aware abort intercept', () => {
|
|
1423
|
+
// Builds a 🛑 reaction on a bot-authored message.
|
|
1424
|
+
function makeStopReaction() {
|
|
1425
|
+
return mockReaction({
|
|
1426
|
+
emoji: { name: '🛑' },
|
|
1427
|
+
message: mockMessage({
|
|
1428
|
+
author: { id: 'bot-1', username: 'TestBot', displayName: 'TestBot' },
|
|
1429
|
+
}),
|
|
1430
|
+
});
|
|
1431
|
+
}
|
|
1432
|
+
it('calls tryAbortAll and returns without AI invocation when no resolvedPrompt', async () => {
|
|
1433
|
+
const tryAbortAllSpy = vi.spyOn(abortRegistry, 'tryAbortAll').mockReturnValue(0);
|
|
1434
|
+
const getOrchestratorSpy = vi.spyOn(forgePlanRegistry, 'getActiveOrchestrator').mockReturnValue(null);
|
|
1435
|
+
try {
|
|
1436
|
+
const params = makeParams();
|
|
1437
|
+
const queue = mockQueue();
|
|
1438
|
+
const handler = createReactionAddHandler(params, queue);
|
|
1439
|
+
await handler(makeStopReaction(), mockUser());
|
|
1440
|
+
expect(tryAbortAllSpy).toHaveBeenCalledOnce();
|
|
1441
|
+
expect(getOrchestratorSpy).toHaveBeenCalledOnce();
|
|
1442
|
+
expect(queue.run).not.toHaveBeenCalled();
|
|
1443
|
+
}
|
|
1444
|
+
finally {
|
|
1445
|
+
tryAbortAllSpy.mockRestore();
|
|
1446
|
+
getOrchestratorSpy.mockRestore();
|
|
1447
|
+
}
|
|
1448
|
+
});
|
|
1449
|
+
it('calls requestCancel on a running forge orchestrator', async () => {
|
|
1450
|
+
const requestCancelFn = vi.fn();
|
|
1451
|
+
const mockOrch = { isRunning: true, requestCancel: requestCancelFn };
|
|
1452
|
+
const tryAbortAllSpy = vi.spyOn(abortRegistry, 'tryAbortAll').mockReturnValue(0);
|
|
1453
|
+
const getOrchestratorSpy = vi.spyOn(forgePlanRegistry, 'getActiveOrchestrator').mockReturnValue(mockOrch);
|
|
1454
|
+
try {
|
|
1455
|
+
const params = makeParams();
|
|
1456
|
+
const queue = mockQueue();
|
|
1457
|
+
const handler = createReactionAddHandler(params, queue);
|
|
1458
|
+
await handler(makeStopReaction(), mockUser());
|
|
1459
|
+
expect(requestCancelFn).toHaveBeenCalledOnce();
|
|
1460
|
+
expect(queue.run).not.toHaveBeenCalled();
|
|
1461
|
+
}
|
|
1462
|
+
finally {
|
|
1463
|
+
tryAbortAllSpy.mockRestore();
|
|
1464
|
+
getOrchestratorSpy.mockRestore();
|
|
1465
|
+
}
|
|
1466
|
+
});
|
|
1467
|
+
it('does not call requestCancel when forge orchestrator is not running', async () => {
|
|
1468
|
+
const requestCancelFn = vi.fn();
|
|
1469
|
+
const mockOrch = { isRunning: false, requestCancel: requestCancelFn };
|
|
1470
|
+
const tryAbortAllSpy = vi.spyOn(abortRegistry, 'tryAbortAll').mockReturnValue(0);
|
|
1471
|
+
const getOrchestratorSpy = vi.spyOn(forgePlanRegistry, 'getActiveOrchestrator').mockReturnValue(mockOrch);
|
|
1472
|
+
try {
|
|
1473
|
+
const params = makeParams();
|
|
1474
|
+
const queue = mockQueue();
|
|
1475
|
+
const handler = createReactionAddHandler(params, queue);
|
|
1476
|
+
await handler(makeStopReaction(), mockUser());
|
|
1477
|
+
expect(requestCancelFn).not.toHaveBeenCalled();
|
|
1478
|
+
}
|
|
1479
|
+
finally {
|
|
1480
|
+
tryAbortAllSpy.mockRestore();
|
|
1481
|
+
getOrchestratorSpy.mockRestore();
|
|
1482
|
+
}
|
|
1483
|
+
});
|
|
1484
|
+
it('increments abort metric when tryAbortAll aborts active streams', async () => {
|
|
1485
|
+
const { MetricsRegistry } = await import('../observability/metrics.js');
|
|
1486
|
+
const metrics = new MetricsRegistry();
|
|
1487
|
+
const tryAbortAllSpy = vi.spyOn(abortRegistry, 'tryAbortAll').mockReturnValue(2);
|
|
1488
|
+
const getOrchestratorSpy = vi.spyOn(forgePlanRegistry, 'getActiveOrchestrator').mockReturnValue(null);
|
|
1489
|
+
try {
|
|
1490
|
+
const params = makeParams({ metrics });
|
|
1491
|
+
const queue = mockQueue();
|
|
1492
|
+
const handler = createReactionAddHandler(params, queue);
|
|
1493
|
+
await handler(makeStopReaction(), mockUser());
|
|
1494
|
+
const snap = metrics.snapshot();
|
|
1495
|
+
expect(snap.counters['discord.reaction.abort']).toBe(1);
|
|
1496
|
+
}
|
|
1497
|
+
finally {
|
|
1498
|
+
tryAbortAllSpy.mockRestore();
|
|
1499
|
+
getOrchestratorSpy.mockRestore();
|
|
1500
|
+
}
|
|
1501
|
+
});
|
|
1502
|
+
it('does not increment abort metric when no streams were active', async () => {
|
|
1503
|
+
const { MetricsRegistry } = await import('../observability/metrics.js');
|
|
1504
|
+
const metrics = new MetricsRegistry();
|
|
1505
|
+
const tryAbortAllSpy = vi.spyOn(abortRegistry, 'tryAbortAll').mockReturnValue(0);
|
|
1506
|
+
const getOrchestratorSpy = vi.spyOn(forgePlanRegistry, 'getActiveOrchestrator').mockReturnValue(null);
|
|
1507
|
+
try {
|
|
1508
|
+
const params = makeParams({ metrics });
|
|
1509
|
+
const queue = mockQueue();
|
|
1510
|
+
const handler = createReactionAddHandler(params, queue);
|
|
1511
|
+
await handler(makeStopReaction(), mockUser());
|
|
1512
|
+
const snap = metrics.snapshot();
|
|
1513
|
+
expect(snap.counters['discord.reaction.abort']).toBeUndefined();
|
|
1514
|
+
}
|
|
1515
|
+
finally {
|
|
1516
|
+
tryAbortAllSpy.mockRestore();
|
|
1517
|
+
getOrchestratorSpy.mockRestore();
|
|
1518
|
+
}
|
|
1519
|
+
});
|
|
1520
|
+
it('skips the intercept when resolvedPrompt is set — proceeds to AI invocation', async () => {
|
|
1521
|
+
const promptSpy = vi.spyOn(reactionPrompts, 'tryResolveReactionPrompt').mockReturnValue({ question: 'Use 🛑?', chosenEmoji: '🛑' });
|
|
1522
|
+
const tryAbortAllSpy = vi.spyOn(abortRegistry, 'tryAbortAll').mockReturnValue(0);
|
|
1523
|
+
try {
|
|
1524
|
+
const params = makeParams();
|
|
1525
|
+
const queue = mockQueue();
|
|
1526
|
+
const handler = createReactionAddHandler(params, queue);
|
|
1527
|
+
await handler(makeStopReaction(), mockUser());
|
|
1528
|
+
// Intercept was skipped — tryAbortAll not called.
|
|
1529
|
+
expect(tryAbortAllSpy).not.toHaveBeenCalled();
|
|
1530
|
+
// AI invocation proceeds normally.
|
|
1531
|
+
expect(queue.run).toHaveBeenCalledOnce();
|
|
1532
|
+
}
|
|
1533
|
+
finally {
|
|
1534
|
+
promptSpy.mockRestore();
|
|
1535
|
+
tryAbortAllSpy.mockRestore();
|
|
1536
|
+
}
|
|
1537
|
+
});
|
|
1538
|
+
});
|
|
1539
|
+
describe('in-flight reply registry cleanup', () => {
|
|
1540
|
+
afterEach(() => {
|
|
1541
|
+
resetInFlight();
|
|
1542
|
+
});
|
|
1543
|
+
it('no leaked registry entries after normal completion', async () => {
|
|
1544
|
+
const params = makeParams();
|
|
1545
|
+
const queue = mockQueue();
|
|
1546
|
+
const handler = createReactionAddHandler(params, queue);
|
|
1547
|
+
const reaction = mockReaction();
|
|
1548
|
+
await handler(reaction, mockUser());
|
|
1549
|
+
expect(inFlightReplyCount()).toBe(0);
|
|
1550
|
+
});
|
|
1551
|
+
it('no leaked registry entries after runtime error', async () => {
|
|
1552
|
+
const params = makeParams({ runtime: makeMockRuntimeError('timeout') });
|
|
1553
|
+
const queue = mockQueue();
|
|
1554
|
+
const handler = createReactionAddHandler(params, queue);
|
|
1555
|
+
const reaction = mockReaction();
|
|
1556
|
+
await handler(reaction, mockUser());
|
|
1557
|
+
expect(inFlightReplyCount()).toBe(0);
|
|
1558
|
+
});
|
|
1559
|
+
it('no leaked registry entries on handler exception', async () => {
|
|
1560
|
+
const runtime = {
|
|
1561
|
+
id: 'claude_code',
|
|
1562
|
+
capabilities: new Set(['streaming_text']),
|
|
1563
|
+
async *invoke() {
|
|
1564
|
+
throw new Error('unexpected crash');
|
|
1565
|
+
},
|
|
1566
|
+
};
|
|
1567
|
+
const params = makeParams({ runtime });
|
|
1568
|
+
const queue = mockQueue();
|
|
1569
|
+
const handler = createReactionAddHandler(params, queue);
|
|
1570
|
+
const reaction = mockReaction();
|
|
1571
|
+
await handler(reaction, mockUser());
|
|
1572
|
+
expect(inFlightReplyCount()).toBe(0);
|
|
1573
|
+
});
|
|
1574
|
+
});
|