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,677 @@
|
|
|
1
|
+
import { describe, expect, it, vi, afterEach } from 'vitest';
|
|
2
|
+
import { ChannelType } from 'discord.js';
|
|
3
|
+
import { createMessageCreateHandler } from './discord.js';
|
|
4
|
+
import { hasQueryAction, QUERY_ACTION_TYPES } from './discord/action-categories.js';
|
|
5
|
+
import { inFlightReplyCount, _resetForTest as resetInFlight } from './discord/inflight-replies.js';
|
|
6
|
+
import * as abortRegistry from './discord/abort-registry.js';
|
|
7
|
+
import { _resetDestructiveConfirmationForTest as resetDestructiveConfirm } from './discord/destructive-confirmation.js';
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
// Helpers
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
function makeQueue() {
|
|
12
|
+
return {
|
|
13
|
+
run: vi.fn(async (_key, fn) => fn()),
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
function makeMsg(overrides = {}) {
|
|
17
|
+
const replyObj = { edit: vi.fn(async () => { }), delete: vi.fn(async () => { }) };
|
|
18
|
+
const guild = makeMockGuild([
|
|
19
|
+
{ id: 'cat1', name: 'Dev', type: ChannelType.GuildCategory },
|
|
20
|
+
{ id: 'ch1', name: 'general', type: ChannelType.GuildText, parentName: 'Dev' },
|
|
21
|
+
]);
|
|
22
|
+
return {
|
|
23
|
+
author: { id: '123', bot: false, displayName: 'User', username: 'user' },
|
|
24
|
+
guildId: 'guild',
|
|
25
|
+
guild,
|
|
26
|
+
channelId: 'chan',
|
|
27
|
+
channel: { send: vi.fn(async () => ({ edit: vi.fn(async () => { }), delete: vi.fn(async () => { }) })), isThread: () => false, name: 'general' },
|
|
28
|
+
content: 'list all channels',
|
|
29
|
+
reply: vi.fn(async () => replyObj),
|
|
30
|
+
id: 'msg1',
|
|
31
|
+
client: {},
|
|
32
|
+
...overrides,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
function makeMockGuild(channels) {
|
|
36
|
+
const cache = new Map();
|
|
37
|
+
for (const ch of channels) {
|
|
38
|
+
cache.set(ch.id, {
|
|
39
|
+
id: ch.id,
|
|
40
|
+
name: ch.name,
|
|
41
|
+
type: ch.type,
|
|
42
|
+
parent: ch.parentName ? { name: ch.parentName } : null,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
return {
|
|
46
|
+
channels: {
|
|
47
|
+
cache: {
|
|
48
|
+
find: (fn) => {
|
|
49
|
+
for (const ch of cache.values())
|
|
50
|
+
if (fn(ch))
|
|
51
|
+
return ch;
|
|
52
|
+
return undefined;
|
|
53
|
+
},
|
|
54
|
+
values: () => cache.values(),
|
|
55
|
+
get size() { return cache.size; },
|
|
56
|
+
},
|
|
57
|
+
create: vi.fn(async (opts) => ({ name: opts.name, id: 'new-id' })),
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
function baseParams(runtimeOverride, overrides = {}) {
|
|
62
|
+
return {
|
|
63
|
+
allowUserIds: new Set(['123']),
|
|
64
|
+
botDisplayName: 'TestBot',
|
|
65
|
+
runtime: runtimeOverride,
|
|
66
|
+
sessionManager: { getOrCreate: vi.fn(async () => 'sess') },
|
|
67
|
+
workspaceCwd: '/tmp',
|
|
68
|
+
projectCwd: '/tmp',
|
|
69
|
+
groupsDir: '/tmp',
|
|
70
|
+
useGroupDirCwd: false,
|
|
71
|
+
runtimeModel: 'opus',
|
|
72
|
+
runtimeTools: [],
|
|
73
|
+
runtimeTimeoutMs: 1000,
|
|
74
|
+
requireChannelContext: false,
|
|
75
|
+
autoIndexChannelContext: false,
|
|
76
|
+
autoJoinThreads: false,
|
|
77
|
+
useRuntimeSessions: true,
|
|
78
|
+
discordActionsEnabled: true,
|
|
79
|
+
discordActionsChannels: true,
|
|
80
|
+
discordActionsMessaging: false,
|
|
81
|
+
discordActionsGuild: false,
|
|
82
|
+
discordActionsModeration: false,
|
|
83
|
+
discordActionsPolls: false,
|
|
84
|
+
discordActionsTasks: false,
|
|
85
|
+
discordActionsBotProfile: false,
|
|
86
|
+
messageHistoryBudget: 0,
|
|
87
|
+
summaryEnabled: false,
|
|
88
|
+
summaryModel: 'haiku',
|
|
89
|
+
summaryMaxChars: 2000,
|
|
90
|
+
summaryEveryNTurns: 5,
|
|
91
|
+
summaryDataDir: '/tmp/summaries',
|
|
92
|
+
summaryToDurableEnabled: false,
|
|
93
|
+
shortTermMemoryEnabled: false,
|
|
94
|
+
shortTermDataDir: '/tmp/shortterm',
|
|
95
|
+
shortTermMaxEntries: 20,
|
|
96
|
+
shortTermMaxAgeMs: 21600000,
|
|
97
|
+
shortTermInjectMaxChars: 1000,
|
|
98
|
+
durableMemoryEnabled: false,
|
|
99
|
+
durableDataDir: '/tmp/durable',
|
|
100
|
+
durableInjectMaxChars: 2000,
|
|
101
|
+
durableMaxItems: 200,
|
|
102
|
+
memoryCommandsEnabled: false,
|
|
103
|
+
actionFollowupDepth: 3,
|
|
104
|
+
reactionHandlerEnabled: false,
|
|
105
|
+
reactionRemoveHandlerEnabled: false,
|
|
106
|
+
reactionMaxAgeMs: 24 * 60 * 60 * 1000,
|
|
107
|
+
streamStallWarningMs: 0,
|
|
108
|
+
...overrides,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
// hasQueryAction unit tests
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
describe('hasQueryAction', () => {
|
|
115
|
+
it('returns true when a query action type is present', () => {
|
|
116
|
+
expect(hasQueryAction(['channelList'])).toBe(true);
|
|
117
|
+
expect(hasQueryAction(['readMessages', 'sendMessage'])).toBe(true);
|
|
118
|
+
expect(hasQueryAction(['taskShow'])).toBe(true);
|
|
119
|
+
});
|
|
120
|
+
it('returns false when only mutation types are present', () => {
|
|
121
|
+
expect(hasQueryAction(['channelCreate'])).toBe(false);
|
|
122
|
+
expect(hasQueryAction(['sendMessage', 'channelDelete'])).toBe(false);
|
|
123
|
+
});
|
|
124
|
+
it('returns false for empty list', () => {
|
|
125
|
+
expect(hasQueryAction([])).toBe(false);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
describe('QUERY_ACTION_TYPES', () => {
|
|
129
|
+
it('contains all expected query types', () => {
|
|
130
|
+
const expected = [
|
|
131
|
+
'channelList', 'channelInfo', 'threadListArchived',
|
|
132
|
+
'readMessages', 'fetchMessage', 'listPins',
|
|
133
|
+
'memberInfo', 'roleInfo', 'searchMessages', 'eventList',
|
|
134
|
+
'taskList', 'taskShow', 'forgeStatus',
|
|
135
|
+
];
|
|
136
|
+
for (const t of expected) {
|
|
137
|
+
expect(QUERY_ACTION_TYPES.has(t)).toBe(true);
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
it('does not contain mutation types', () => {
|
|
141
|
+
const mutations = ['channelCreate', 'channelDelete', 'sendMessage', 'taskCreate', 'taskClose'];
|
|
142
|
+
for (const t of mutations) {
|
|
143
|
+
expect(QUERY_ACTION_TYPES.has(t)).toBe(false);
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
it('contains forgeStatus (forgeStatus returns data the model must synthesize into a reply)', () => {
|
|
147
|
+
expect(QUERY_ACTION_TYPES.has('forgeStatus')).toBe(true);
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
// ---------------------------------------------------------------------------
|
|
151
|
+
// Auto-follow-up integration tests
|
|
152
|
+
// ---------------------------------------------------------------------------
|
|
153
|
+
describe('auto-follow-up for query actions', () => {
|
|
154
|
+
it('triggers follow-up when a query action (channelList) is present', async () => {
|
|
155
|
+
let callCount = 0;
|
|
156
|
+
const runtime = {
|
|
157
|
+
invoke: vi.fn(async function* () {
|
|
158
|
+
callCount++;
|
|
159
|
+
if (callCount === 1) {
|
|
160
|
+
// First call: emit a channelList action.
|
|
161
|
+
yield { type: 'text_final', text: 'Here are the channels:\n<discord-action>{"type":"channelList"}</discord-action>' };
|
|
162
|
+
}
|
|
163
|
+
else {
|
|
164
|
+
// Follow-up call: just respond with analysis.
|
|
165
|
+
yield { type: 'text_final', text: 'I can see there are 2 channels in the server.' };
|
|
166
|
+
}
|
|
167
|
+
}),
|
|
168
|
+
};
|
|
169
|
+
const handler = createMessageCreateHandler(baseParams(runtime), makeQueue());
|
|
170
|
+
await handler(makeMsg());
|
|
171
|
+
expect(runtime.invoke).toHaveBeenCalledTimes(2);
|
|
172
|
+
// Second call should have auto-follow-up prompt.
|
|
173
|
+
const secondPrompt = runtime.invoke.mock.calls[1][0].prompt;
|
|
174
|
+
expect(secondPrompt).toContain('[Auto-follow-up]');
|
|
175
|
+
expect(secondPrompt).toContain('Done:');
|
|
176
|
+
});
|
|
177
|
+
it('does NOT trigger follow-up for mutation-only actions', async () => {
|
|
178
|
+
const runtime = {
|
|
179
|
+
invoke: vi.fn(async function* () {
|
|
180
|
+
yield { type: 'text_final', text: 'Creating channel:\n<discord-action>{"type":"channelCreate","name":"test"}</discord-action>' };
|
|
181
|
+
}),
|
|
182
|
+
};
|
|
183
|
+
const handler = createMessageCreateHandler(baseParams(runtime), makeQueue());
|
|
184
|
+
await handler(makeMsg());
|
|
185
|
+
expect(runtime.invoke).toHaveBeenCalledTimes(1);
|
|
186
|
+
});
|
|
187
|
+
it('triggers follow-up when mixed query+mutation actions are present', async () => {
|
|
188
|
+
let callCount = 0;
|
|
189
|
+
const runtime = {
|
|
190
|
+
invoke: vi.fn(async function* () {
|
|
191
|
+
callCount++;
|
|
192
|
+
if (callCount === 1) {
|
|
193
|
+
yield {
|
|
194
|
+
type: 'text_final',
|
|
195
|
+
text: 'Listing and creating:\n<discord-action>{"type":"channelList"}</discord-action>\n<discord-action>{"type":"channelCreate","name":"new-ch"}</discord-action>',
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
else {
|
|
199
|
+
yield { type: 'text_final', text: 'Done analyzing the channel list.' };
|
|
200
|
+
}
|
|
201
|
+
}),
|
|
202
|
+
};
|
|
203
|
+
const handler = createMessageCreateHandler(baseParams(runtime), makeQueue());
|
|
204
|
+
await handler(makeMsg());
|
|
205
|
+
// Should follow up because channelList (query) was present and succeeded.
|
|
206
|
+
expect(runtime.invoke).toHaveBeenCalledTimes(2);
|
|
207
|
+
});
|
|
208
|
+
it('respects depth limit (no infinite loops)', async () => {
|
|
209
|
+
// Each invoke produces a channelList action, would loop forever without depth limit.
|
|
210
|
+
const runtime = {
|
|
211
|
+
invoke: vi.fn(async function* () {
|
|
212
|
+
yield { type: 'text_final', text: 'Checking:\n<discord-action>{"type":"channelList"}</discord-action>' };
|
|
213
|
+
}),
|
|
214
|
+
};
|
|
215
|
+
const handler = createMessageCreateHandler(baseParams(runtime, { actionFollowupDepth: 2 }), makeQueue());
|
|
216
|
+
await handler(makeMsg());
|
|
217
|
+
// Initial invoke + 2 follow-ups = 3 total.
|
|
218
|
+
expect(runtime.invoke).toHaveBeenCalledTimes(3);
|
|
219
|
+
});
|
|
220
|
+
it('does not follow up when depth is 0 (feature disabled)', async () => {
|
|
221
|
+
const runtime = {
|
|
222
|
+
invoke: vi.fn(async function* () {
|
|
223
|
+
yield { type: 'text_final', text: 'List:\n<discord-action>{"type":"channelList"}</discord-action>' };
|
|
224
|
+
}),
|
|
225
|
+
};
|
|
226
|
+
const handler = createMessageCreateHandler(baseParams(runtime, { actionFollowupDepth: 0 }), makeQueue());
|
|
227
|
+
await handler(makeMsg());
|
|
228
|
+
expect(runtime.invoke).toHaveBeenCalledTimes(1);
|
|
229
|
+
});
|
|
230
|
+
it('suppresses trivially short follow-up responses', async () => {
|
|
231
|
+
let callCount = 0;
|
|
232
|
+
const runtime = {
|
|
233
|
+
invoke: vi.fn(async function* () {
|
|
234
|
+
callCount++;
|
|
235
|
+
if (callCount === 1) {
|
|
236
|
+
yield { type: 'text_final', text: 'Listing:\n<discord-action>{"type":"channelList"}</discord-action>' };
|
|
237
|
+
}
|
|
238
|
+
else {
|
|
239
|
+
// Trivially short response with no actions.
|
|
240
|
+
yield { type: 'text_final', text: 'Got it.' };
|
|
241
|
+
}
|
|
242
|
+
}),
|
|
243
|
+
};
|
|
244
|
+
const msg = makeMsg();
|
|
245
|
+
const handler = createMessageCreateHandler(baseParams(runtime), makeQueue());
|
|
246
|
+
await handler(msg);
|
|
247
|
+
// Should invoke twice (initial + follow-up), but the follow-up message should be deleted.
|
|
248
|
+
expect(runtime.invoke).toHaveBeenCalledTimes(2);
|
|
249
|
+
// The follow-up placeholder is created via channel.send, and should have delete() called.
|
|
250
|
+
const sendResult = await msg.channel.send.mock.results[0]?.value;
|
|
251
|
+
if (sendResult) {
|
|
252
|
+
expect(sendResult.delete).toHaveBeenCalled();
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
it('does not suppress a substantial follow-up response', async () => {
|
|
256
|
+
let callCount = 0;
|
|
257
|
+
const runtime = {
|
|
258
|
+
invoke: vi.fn(async function* () {
|
|
259
|
+
callCount++;
|
|
260
|
+
if (callCount === 1) {
|
|
261
|
+
yield { type: 'text_final', text: 'Listing:\n<discord-action>{"type":"channelList"}</discord-action>' };
|
|
262
|
+
}
|
|
263
|
+
else {
|
|
264
|
+
yield { type: 'text_final', text: 'Based on the channel list, I can see you have a Dev category with a general channel, plus a random channel at the top level.' };
|
|
265
|
+
}
|
|
266
|
+
}),
|
|
267
|
+
};
|
|
268
|
+
const msg = makeMsg();
|
|
269
|
+
const handler = createMessageCreateHandler(baseParams(runtime), makeQueue());
|
|
270
|
+
await handler(msg);
|
|
271
|
+
expect(runtime.invoke).toHaveBeenCalledTimes(2);
|
|
272
|
+
// The follow-up message should be edited (not deleted).
|
|
273
|
+
const sendResult = await msg.channel.send.mock.results[0]?.value;
|
|
274
|
+
if (sendResult) {
|
|
275
|
+
expect(sendResult.edit).toHaveBeenCalled();
|
|
276
|
+
expect(sendResult.delete).not.toHaveBeenCalled();
|
|
277
|
+
}
|
|
278
|
+
});
|
|
279
|
+
it('shows unavailable action types when actions are stripped', async () => {
|
|
280
|
+
const runtime = {
|
|
281
|
+
invoke: vi.fn(async function* () {
|
|
282
|
+
yield { type: 'text_final', text: '<discord-action>{"type":"notARealAction"}</discord-action>' };
|
|
283
|
+
}),
|
|
284
|
+
};
|
|
285
|
+
const msg = makeMsg();
|
|
286
|
+
const handler = createMessageCreateHandler(baseParams(runtime), makeQueue());
|
|
287
|
+
await handler(msg);
|
|
288
|
+
expect(runtime.invoke).toHaveBeenCalledTimes(1);
|
|
289
|
+
const replyObj = await msg.reply.mock.results[0]?.value;
|
|
290
|
+
const lastEditContent = replyObj?.edit?.mock?.calls?.[replyObj.edit.mock.calls.length - 1]?.[0]?.content ?? '';
|
|
291
|
+
expect(lastEditContent).toContain('Ignored unavailable action type:');
|
|
292
|
+
expect(lastEditContent).toContain('`notARealAction`');
|
|
293
|
+
expect(replyObj.delete).not.toHaveBeenCalled();
|
|
294
|
+
});
|
|
295
|
+
it('follow-up runs inside queue (serialization preserved)', async () => {
|
|
296
|
+
let callCount = 0;
|
|
297
|
+
const runtime = {
|
|
298
|
+
invoke: vi.fn(async function* () {
|
|
299
|
+
callCount++;
|
|
300
|
+
if (callCount === 1) {
|
|
301
|
+
yield { type: 'text_final', text: 'Listing:\n<discord-action>{"type":"channelList"}</discord-action>' };
|
|
302
|
+
}
|
|
303
|
+
else {
|
|
304
|
+
yield { type: 'text_final', text: 'Analysis of channels complete with detailed information.' };
|
|
305
|
+
}
|
|
306
|
+
}),
|
|
307
|
+
};
|
|
308
|
+
const queue = makeQueue();
|
|
309
|
+
const handler = createMessageCreateHandler(baseParams(runtime), queue);
|
|
310
|
+
await handler(makeMsg());
|
|
311
|
+
// Queue.run is called once for the entire message handling (including follow-ups).
|
|
312
|
+
expect(queue.run).toHaveBeenCalledTimes(1);
|
|
313
|
+
});
|
|
314
|
+
it('suppresses sendMessage Done line from posted message but keeps it in follow-up prompt', async () => {
|
|
315
|
+
let callCount = 0;
|
|
316
|
+
const runtime = {
|
|
317
|
+
invoke: vi.fn(async function* () {
|
|
318
|
+
callCount++;
|
|
319
|
+
if (callCount === 1) {
|
|
320
|
+
// First call: emit a channelList (query) + sendMessage action.
|
|
321
|
+
yield {
|
|
322
|
+
type: 'text_final',
|
|
323
|
+
text: 'Here you go:\n<discord-action>{"type":"channelList"}</discord-action>\n<discord-action>{"type":"sendMessage","channel":"general","content":"hello"}</discord-action>',
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
else {
|
|
327
|
+
yield { type: 'text_final', text: 'Analysis complete with enough length to avoid suppression threshold for testing.' };
|
|
328
|
+
}
|
|
329
|
+
}),
|
|
330
|
+
};
|
|
331
|
+
// Build a guild mock that supports both cache.get() and cache.find() with a sendable channel.
|
|
332
|
+
const generalCh = {
|
|
333
|
+
id: 'ch1',
|
|
334
|
+
name: 'general',
|
|
335
|
+
type: ChannelType.GuildText,
|
|
336
|
+
parent: { name: 'Dev' },
|
|
337
|
+
send: vi.fn(async () => ({})),
|
|
338
|
+
};
|
|
339
|
+
const catCh = {
|
|
340
|
+
id: 'cat1',
|
|
341
|
+
name: 'Dev',
|
|
342
|
+
type: ChannelType.GuildCategory,
|
|
343
|
+
parent: null,
|
|
344
|
+
};
|
|
345
|
+
const channelsMap = new Map([
|
|
346
|
+
['ch1', generalCh],
|
|
347
|
+
['cat1', catCh],
|
|
348
|
+
]);
|
|
349
|
+
const guild = {
|
|
350
|
+
channels: {
|
|
351
|
+
cache: {
|
|
352
|
+
get: (id) => channelsMap.get(id),
|
|
353
|
+
find: (fn) => {
|
|
354
|
+
for (const ch of channelsMap.values())
|
|
355
|
+
if (fn(ch))
|
|
356
|
+
return ch;
|
|
357
|
+
return undefined;
|
|
358
|
+
},
|
|
359
|
+
values: () => channelsMap.values(),
|
|
360
|
+
get size() { return channelsMap.size; },
|
|
361
|
+
},
|
|
362
|
+
create: vi.fn(async (opts) => ({ name: opts.name, id: 'new-id' })),
|
|
363
|
+
},
|
|
364
|
+
};
|
|
365
|
+
const handler = createMessageCreateHandler(baseParams(runtime, { discordActionsMessaging: true }), makeQueue());
|
|
366
|
+
const msg = makeMsg({ guild });
|
|
367
|
+
await handler(msg);
|
|
368
|
+
expect(runtime.invoke).toHaveBeenCalledTimes(2);
|
|
369
|
+
// The follow-up prompt should contain the sendMessage Done line (all results unfiltered).
|
|
370
|
+
const secondPrompt = runtime.invoke.mock.calls[1][0].prompt;
|
|
371
|
+
expect(secondPrompt).toContain('[Auto-follow-up]');
|
|
372
|
+
expect(secondPrompt).toContain('Done: Sent message');
|
|
373
|
+
// The posted message (first reply edit) should NOT contain 'Done: Sent message'.
|
|
374
|
+
const replyObj = await msg.reply.mock.results[0]?.value;
|
|
375
|
+
if (replyObj) {
|
|
376
|
+
const allEditContents = replyObj.edit.mock.calls.map((c) => typeof c[0] === 'string' ? c[0] : c[0]?.content ?? '');
|
|
377
|
+
const postedContent = allEditContents[allEditContents.length - 1];
|
|
378
|
+
expect(postedContent).not.toContain('Done: Sent message');
|
|
379
|
+
}
|
|
380
|
+
});
|
|
381
|
+
it('suppresses sendMessage targeting parent forum when in a bead thread', async () => {
|
|
382
|
+
const runtime = {
|
|
383
|
+
invoke: vi.fn(async function* () {
|
|
384
|
+
yield {
|
|
385
|
+
type: 'text_final',
|
|
386
|
+
text: 'Here is my response.\n<discord-action>{"type":"sendMessage","channel":"forum1","content":"hello"}</discord-action>',
|
|
387
|
+
};
|
|
388
|
+
}),
|
|
389
|
+
};
|
|
390
|
+
// Build a guild with a forum channel and thread channel in the cache.
|
|
391
|
+
const forumCh = {
|
|
392
|
+
id: 'forum1',
|
|
393
|
+
name: 'beads',
|
|
394
|
+
type: ChannelType.GuildForum,
|
|
395
|
+
parent: null,
|
|
396
|
+
send: vi.fn(async () => ({})),
|
|
397
|
+
};
|
|
398
|
+
const threadCh = {
|
|
399
|
+
id: 'thread1',
|
|
400
|
+
name: 'my-bead',
|
|
401
|
+
type: ChannelType.PublicThread,
|
|
402
|
+
parentId: 'forum1',
|
|
403
|
+
parent: { name: 'beads' },
|
|
404
|
+
isThread: () => true,
|
|
405
|
+
joinable: false,
|
|
406
|
+
joined: true,
|
|
407
|
+
send: vi.fn(async () => ({ edit: vi.fn(async () => { }), delete: vi.fn(async () => { }) })),
|
|
408
|
+
};
|
|
409
|
+
const channelsMap = new Map([
|
|
410
|
+
['forum1', forumCh],
|
|
411
|
+
['thread1', threadCh],
|
|
412
|
+
]);
|
|
413
|
+
const guild = {
|
|
414
|
+
channels: {
|
|
415
|
+
cache: {
|
|
416
|
+
get: (id) => channelsMap.get(id),
|
|
417
|
+
find: (fn) => {
|
|
418
|
+
for (const ch of channelsMap.values())
|
|
419
|
+
if (fn(ch))
|
|
420
|
+
return ch;
|
|
421
|
+
return undefined;
|
|
422
|
+
},
|
|
423
|
+
values: () => channelsMap.values(),
|
|
424
|
+
get size() { return channelsMap.size; },
|
|
425
|
+
},
|
|
426
|
+
create: vi.fn(async (opts) => ({ name: opts.name, id: 'new-id' })),
|
|
427
|
+
},
|
|
428
|
+
};
|
|
429
|
+
const handler = createMessageCreateHandler(baseParams(runtime, { discordActionsMessaging: true }), makeQueue());
|
|
430
|
+
const msg = makeMsg({
|
|
431
|
+
guild,
|
|
432
|
+
channel: threadCh,
|
|
433
|
+
channelId: 'thread1',
|
|
434
|
+
});
|
|
435
|
+
await handler(msg);
|
|
436
|
+
// The reply should contain the prose text.
|
|
437
|
+
const replyObj = await msg.reply.mock.results[0]?.value;
|
|
438
|
+
expect(replyObj).toBeDefined();
|
|
439
|
+
const allEditContents = replyObj.edit.mock.calls.map((c) => typeof c[0] === 'string' ? c[0] : c[0]?.content ?? '');
|
|
440
|
+
const postedContent = allEditContents[allEditContents.length - 1];
|
|
441
|
+
// Prose should be present.
|
|
442
|
+
expect(postedContent).toContain('Here is my response.');
|
|
443
|
+
// Should NOT contain a Failed: line or forum channel error text.
|
|
444
|
+
expect(postedContent).not.toContain('Failed:');
|
|
445
|
+
expect(postedContent).not.toContain('forum channel');
|
|
446
|
+
// Reply should NOT have been deleted (response was posted, not suppressed as empty).
|
|
447
|
+
expect(replyObj.delete).not.toHaveBeenCalled();
|
|
448
|
+
// Forum channel's .send() should NOT have been called.
|
|
449
|
+
expect(forumCh.send).not.toHaveBeenCalled();
|
|
450
|
+
});
|
|
451
|
+
it('does not follow up when query action fails', async () => {
|
|
452
|
+
// Guild with no channels — channelList still succeeds with empty list.
|
|
453
|
+
// Use a channelInfo with bad ID to get a failure.
|
|
454
|
+
const runtime = {
|
|
455
|
+
invoke: vi.fn(async function* () {
|
|
456
|
+
yield { type: 'text_final', text: 'Info:\n<discord-action>{"type":"channelInfo","channelId":"nonexistent"}</discord-action>' };
|
|
457
|
+
}),
|
|
458
|
+
};
|
|
459
|
+
const handler = createMessageCreateHandler(baseParams(runtime), makeQueue());
|
|
460
|
+
await handler(makeMsg());
|
|
461
|
+
// channelInfo for a non-existent channel fails -> no follow-up.
|
|
462
|
+
expect(runtime.invoke).toHaveBeenCalledTimes(1);
|
|
463
|
+
});
|
|
464
|
+
it('does not execute actions when stream is aborted without a runtime error', async () => {
|
|
465
|
+
const runtimeStarted = (() => {
|
|
466
|
+
let resolve;
|
|
467
|
+
const promise = new Promise((r) => { resolve = r; });
|
|
468
|
+
return { promise, resolve };
|
|
469
|
+
})();
|
|
470
|
+
const runtime = {
|
|
471
|
+
invoke: vi.fn(async function* (p) {
|
|
472
|
+
runtimeStarted.resolve();
|
|
473
|
+
yield {
|
|
474
|
+
type: 'text_delta',
|
|
475
|
+
text: 'partial output\n<discord-action>{"type":"channelCreate","name":"unsafe"}</discord-action>',
|
|
476
|
+
};
|
|
477
|
+
await new Promise((resolve) => {
|
|
478
|
+
if (p.signal?.aborted)
|
|
479
|
+
return resolve();
|
|
480
|
+
p.signal?.addEventListener('abort', () => resolve(), { once: true });
|
|
481
|
+
});
|
|
482
|
+
yield { type: 'done' };
|
|
483
|
+
}),
|
|
484
|
+
};
|
|
485
|
+
const msg = makeMsg();
|
|
486
|
+
const handler = createMessageCreateHandler(baseParams(runtime), makeQueue());
|
|
487
|
+
const pending = handler(msg);
|
|
488
|
+
await runtimeStarted.promise;
|
|
489
|
+
let abortedCount = 0;
|
|
490
|
+
for (let i = 0; i < 25 && abortedCount === 0; i++) {
|
|
491
|
+
abortedCount = abortRegistry.tryAbortAll();
|
|
492
|
+
if (abortedCount === 0)
|
|
493
|
+
await Promise.resolve();
|
|
494
|
+
}
|
|
495
|
+
expect(abortedCount).toBe(1);
|
|
496
|
+
await pending;
|
|
497
|
+
expect(runtime.invoke).toHaveBeenCalledTimes(1);
|
|
498
|
+
expect(msg.guild.channels.create).not.toHaveBeenCalled();
|
|
499
|
+
});
|
|
500
|
+
it('drains all queued streaming edits before finalizing the reply', async () => {
|
|
501
|
+
vi.useFakeTimers();
|
|
502
|
+
try {
|
|
503
|
+
const unblockRuntime = (() => {
|
|
504
|
+
let resolve;
|
|
505
|
+
const promise = new Promise((r) => { resolve = r; });
|
|
506
|
+
return { promise, resolve };
|
|
507
|
+
})();
|
|
508
|
+
const unblockFirstStallEdit = (() => {
|
|
509
|
+
let resolve;
|
|
510
|
+
const promise = new Promise((r) => { resolve = r; });
|
|
511
|
+
return { promise, resolve };
|
|
512
|
+
})();
|
|
513
|
+
const runtimeStarted = (() => {
|
|
514
|
+
let resolve;
|
|
515
|
+
const promise = new Promise((r) => { resolve = r; });
|
|
516
|
+
return { promise, resolve };
|
|
517
|
+
})();
|
|
518
|
+
let stallEditCalls = 0;
|
|
519
|
+
const runtime = {
|
|
520
|
+
invoke: vi.fn(async function* () {
|
|
521
|
+
runtimeStarted.resolve();
|
|
522
|
+
await unblockRuntime.promise;
|
|
523
|
+
yield { type: 'done' };
|
|
524
|
+
}),
|
|
525
|
+
};
|
|
526
|
+
const replyObj = {
|
|
527
|
+
edit: vi.fn().mockImplementation(async (payload) => {
|
|
528
|
+
stallEditCalls++;
|
|
529
|
+
if (stallEditCalls === 1) {
|
|
530
|
+
await unblockFirstStallEdit.promise;
|
|
531
|
+
}
|
|
532
|
+
}),
|
|
533
|
+
delete: vi.fn().mockResolvedValue(undefined),
|
|
534
|
+
};
|
|
535
|
+
const msg = makeMsg({ reply: vi.fn(async () => replyObj) });
|
|
536
|
+
const handler = createMessageCreateHandler(baseParams(runtime, { streamStallWarningMs: 1 }), makeQueue());
|
|
537
|
+
let settled = false;
|
|
538
|
+
const pending = handler(msg).then(() => { settled = true; });
|
|
539
|
+
await runtimeStarted.promise;
|
|
540
|
+
await vi.advanceTimersByTimeAsync(10_000);
|
|
541
|
+
expect(stallEditCalls).toBe(1);
|
|
542
|
+
unblockRuntime.resolve();
|
|
543
|
+
await Promise.resolve();
|
|
544
|
+
await Promise.resolve();
|
|
545
|
+
expect(settled).toBe(false);
|
|
546
|
+
unblockFirstStallEdit.resolve();
|
|
547
|
+
await pending;
|
|
548
|
+
expect(settled).toBe(true);
|
|
549
|
+
}
|
|
550
|
+
finally {
|
|
551
|
+
vi.useRealTimers();
|
|
552
|
+
}
|
|
553
|
+
});
|
|
554
|
+
});
|
|
555
|
+
describe('destructive action confirmation flow', () => {
|
|
556
|
+
afterEach(() => {
|
|
557
|
+
resetDestructiveConfirm();
|
|
558
|
+
});
|
|
559
|
+
it('requires !confirm token before executing destructive actions', async () => {
|
|
560
|
+
const ban = vi.fn(async () => { });
|
|
561
|
+
const guild = {
|
|
562
|
+
members: {
|
|
563
|
+
fetch: vi.fn(async () => ({ displayName: 'BadUser', ban })),
|
|
564
|
+
},
|
|
565
|
+
channels: { cache: { find: vi.fn(), values: vi.fn() } },
|
|
566
|
+
};
|
|
567
|
+
const runtime = {
|
|
568
|
+
invoke: vi.fn(async function* () {
|
|
569
|
+
yield { type: 'text_final', text: '<discord-action>{"type":"ban","userId":"42"}</discord-action>' };
|
|
570
|
+
}),
|
|
571
|
+
};
|
|
572
|
+
const handler = createMessageCreateHandler(baseParams(runtime, { discordActionsModeration: true }), makeQueue());
|
|
573
|
+
const msg1 = makeMsg({ guild, content: 'ban that user' });
|
|
574
|
+
await handler(msg1);
|
|
575
|
+
const firstReply = await msg1.reply.mock.results[0]?.value;
|
|
576
|
+
const firstContent = String(firstReply?.edit?.mock?.calls?.[firstReply.edit.mock.calls.length - 1]?.[0]?.content ?? '');
|
|
577
|
+
const token = /!confirm\s+([a-z0-9_-]{6,64})/i.exec(firstContent)?.[1];
|
|
578
|
+
expect(token).toBeTruthy();
|
|
579
|
+
expect(ban).not.toHaveBeenCalled();
|
|
580
|
+
const msg2 = makeMsg({ guild, content: `!confirm ${token}` });
|
|
581
|
+
await handler(msg2);
|
|
582
|
+
expect(runtime.invoke).toHaveBeenCalledTimes(1);
|
|
583
|
+
expect(ban).toHaveBeenCalledOnce();
|
|
584
|
+
const confirmReply = msg2.reply.mock.calls[0]?.[0]?.content ?? '';
|
|
585
|
+
expect(confirmReply).toContain('Confirmed `ban`.');
|
|
586
|
+
});
|
|
587
|
+
});
|
|
588
|
+
// ---------------------------------------------------------------------------
|
|
589
|
+
// In-flight reply cleanup verification
|
|
590
|
+
// ---------------------------------------------------------------------------
|
|
591
|
+
describe('in-flight reply registry cleanup', () => {
|
|
592
|
+
afterEach(() => {
|
|
593
|
+
resetInFlight();
|
|
594
|
+
abortRegistry._resetForTest();
|
|
595
|
+
});
|
|
596
|
+
it('no leaked registry entries after normal completion', async () => {
|
|
597
|
+
const runtime = {
|
|
598
|
+
invoke: vi.fn(async function* () {
|
|
599
|
+
yield { type: 'text_final', text: 'Hello there!' };
|
|
600
|
+
}),
|
|
601
|
+
};
|
|
602
|
+
const handler = createMessageCreateHandler(baseParams(runtime), makeQueue());
|
|
603
|
+
await handler(makeMsg());
|
|
604
|
+
expect(inFlightReplyCount()).toBe(0);
|
|
605
|
+
});
|
|
606
|
+
it('no leaked registry entries after follow-up completes', async () => {
|
|
607
|
+
let callCount = 0;
|
|
608
|
+
const runtime = {
|
|
609
|
+
invoke: vi.fn(async function* () {
|
|
610
|
+
callCount++;
|
|
611
|
+
if (callCount === 1) {
|
|
612
|
+
yield { type: 'text_final', text: 'Listing:\n<discord-action>{"type":"channelList"}</discord-action>' };
|
|
613
|
+
}
|
|
614
|
+
else {
|
|
615
|
+
yield { type: 'text_final', text: 'Analysis complete with sufficient length to avoid suppression threshold limits.' };
|
|
616
|
+
}
|
|
617
|
+
}),
|
|
618
|
+
};
|
|
619
|
+
const handler = createMessageCreateHandler(baseParams(runtime), makeQueue());
|
|
620
|
+
await handler(makeMsg());
|
|
621
|
+
expect(runtime.invoke).toHaveBeenCalledTimes(2);
|
|
622
|
+
expect(inFlightReplyCount()).toBe(0);
|
|
623
|
+
});
|
|
624
|
+
it('no leaked registry entries after runtime error', async () => {
|
|
625
|
+
const runtime = {
|
|
626
|
+
invoke: vi.fn(async function* () {
|
|
627
|
+
yield { type: 'error', message: 'runtime crashed' };
|
|
628
|
+
}),
|
|
629
|
+
};
|
|
630
|
+
const handler = createMessageCreateHandler(baseParams(runtime), makeQueue());
|
|
631
|
+
await handler(makeMsg());
|
|
632
|
+
expect(inFlightReplyCount()).toBe(0);
|
|
633
|
+
});
|
|
634
|
+
it('requireChannelContext early return: edits reply with error, does not delete', async () => {
|
|
635
|
+
const replyObj = { edit: vi.fn(async () => { }), delete: vi.fn(async () => { }) };
|
|
636
|
+
const runtime = {
|
|
637
|
+
invoke: vi.fn(async function* () {
|
|
638
|
+
yield { type: 'text_final', text: 'should not reach' };
|
|
639
|
+
}),
|
|
640
|
+
};
|
|
641
|
+
const handler = createMessageCreateHandler(baseParams(runtime, { requireChannelContext: true }), makeQueue());
|
|
642
|
+
await handler(makeMsg({ reply: vi.fn(async () => replyObj) }));
|
|
643
|
+
// Runtime should not have been invoked (early return before invoke).
|
|
644
|
+
expect(runtime.invoke).not.toHaveBeenCalled();
|
|
645
|
+
// The reply should be edited with an error message, not deleted.
|
|
646
|
+
expect(replyObj.edit).toHaveBeenCalled();
|
|
647
|
+
expect(replyObj.delete).not.toHaveBeenCalled();
|
|
648
|
+
expect(inFlightReplyCount()).toBe(0);
|
|
649
|
+
});
|
|
650
|
+
it('context-gathering failure: edits reply with error, does not delete', async () => {
|
|
651
|
+
const replyObj = { edit: vi.fn(async () => { }), delete: vi.fn(async () => { }) };
|
|
652
|
+
const runtime = {
|
|
653
|
+
invoke: vi.fn(async function* () {
|
|
654
|
+
yield { type: 'text_final', text: 'should not reach' };
|
|
655
|
+
}),
|
|
656
|
+
};
|
|
657
|
+
// A discordChannelContext whose paContextFiles lists a non-existent required file.
|
|
658
|
+
// inlineContextFiles throws for required files that cannot be read, triggering the
|
|
659
|
+
// inner try-catch before runtime.invoke is ever called.
|
|
660
|
+
const discordChannelContext = {
|
|
661
|
+
contentDir: '/tmp',
|
|
662
|
+
indexPath: '/tmp/index.md',
|
|
663
|
+
paContextFiles: ['/nonexistent-path/required-pa-context.md'],
|
|
664
|
+
channelsDir: '/tmp/channels',
|
|
665
|
+
byChannelId: new Map(),
|
|
666
|
+
dmContextPath: '/tmp/dm.md',
|
|
667
|
+
};
|
|
668
|
+
const handler = createMessageCreateHandler(baseParams(runtime, { discordChannelContext }), makeQueue());
|
|
669
|
+
await handler(makeMsg({ reply: vi.fn(async () => replyObj) }));
|
|
670
|
+
// Runtime should not have been invoked (error occurred before invoke).
|
|
671
|
+
expect(runtime.invoke).not.toHaveBeenCalled();
|
|
672
|
+
// The reply should be edited with an error message, not deleted.
|
|
673
|
+
expect(replyObj.edit).toHaveBeenCalled();
|
|
674
|
+
expect(replyObj.delete).not.toHaveBeenCalled();
|
|
675
|
+
expect(inFlightReplyCount()).toBe(0);
|
|
676
|
+
});
|
|
677
|
+
});
|