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,1431 @@
|
|
|
1
|
+
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
// Mock the task thread cache so integration tests can control task lookups.
|
|
6
|
+
vi.mock('./tasks/thread-cache.js', () => ({
|
|
7
|
+
taskThreadCache: {
|
|
8
|
+
get: vi.fn().mockResolvedValue(null),
|
|
9
|
+
invalidate: vi.fn(),
|
|
10
|
+
},
|
|
11
|
+
}));
|
|
12
|
+
import { taskThreadCache } from './tasks/thread-cache.js';
|
|
13
|
+
import { TaskStore } from './tasks/store.js';
|
|
14
|
+
import { createMessageCreateHandler } from './discord.js';
|
|
15
|
+
import { loadDurableMemory, saveDurableMemory, addItem } from './discord/durable-memory.js';
|
|
16
|
+
import { inlineContextFiles } from './discord/prompt-common.js';
|
|
17
|
+
const mockedCacheGet = vi.mocked(taskThreadCache.get);
|
|
18
|
+
function makeQueue() {
|
|
19
|
+
return {
|
|
20
|
+
run: vi.fn(async (_key, fn) => fn()),
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
function makeMsg(overrides) {
|
|
24
|
+
const replyObj = { edit: vi.fn(async () => { }) };
|
|
25
|
+
return {
|
|
26
|
+
author: { id: '123', bot: false },
|
|
27
|
+
guildId: 'guild',
|
|
28
|
+
channelId: 'chan',
|
|
29
|
+
channel: { send: vi.fn(async () => { }), isThread: () => false, name: 'general' },
|
|
30
|
+
content: 'hello',
|
|
31
|
+
reply: vi.fn(async () => replyObj),
|
|
32
|
+
...overrides,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
/** Create a temp .context/ dir with pa.md and pa-safety.md for test use. */
|
|
36
|
+
async function createPaContextDir() {
|
|
37
|
+
const contextDir = await fs.mkdtemp(path.join(os.tmpdir(), 'pa-ctx-'));
|
|
38
|
+
await fs.writeFile(path.join(contextDir, 'pa.md'), '# PA context', 'utf-8');
|
|
39
|
+
await fs.writeFile(path.join(contextDir, 'pa-safety.md'), '# PA safety rules', 'utf-8');
|
|
40
|
+
return contextDir;
|
|
41
|
+
}
|
|
42
|
+
/** Build a mock DiscordChannelContext using the new pa-based structure. */
|
|
43
|
+
async function buildMockChannelContext(opts) {
|
|
44
|
+
const paContextDir = await createPaContextDir();
|
|
45
|
+
const contentDir = await fs.mkdtemp(path.join(os.tmpdir(), 'ctx-inline-'));
|
|
46
|
+
const channelsDir = path.join(contentDir, 'discord', 'channels');
|
|
47
|
+
await fs.mkdir(channelsDir, { recursive: true });
|
|
48
|
+
for (const f of opts?.channelFiles ?? []) {
|
|
49
|
+
await fs.writeFile(path.join(channelsDir, f.name), f.content, 'utf-8');
|
|
50
|
+
}
|
|
51
|
+
return {
|
|
52
|
+
ctx: {
|
|
53
|
+
contentDir,
|
|
54
|
+
indexPath: path.join(contentDir, 'discord', 'DISCORD.md'),
|
|
55
|
+
paContextFiles: [path.join(paContextDir, 'pa.md'), path.join(paContextDir, 'pa-safety.md')],
|
|
56
|
+
channelsDir,
|
|
57
|
+
byChannelId: opts?.channelEntries ?? new Map(),
|
|
58
|
+
dmContextPath: path.join(channelsDir, 'dm.md'),
|
|
59
|
+
},
|
|
60
|
+
channelsDir,
|
|
61
|
+
paContextDir,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
describe('prompt inlines context file contents', () => {
|
|
65
|
+
it('guild channel inlines PA + channel context files', async () => {
|
|
66
|
+
const queue = makeQueue();
|
|
67
|
+
let seenPrompt = '';
|
|
68
|
+
const runtime = {
|
|
69
|
+
invoke: vi.fn(async function* (p) {
|
|
70
|
+
seenPrompt = p.prompt;
|
|
71
|
+
yield { type: 'text_final', text: 'ok' };
|
|
72
|
+
}),
|
|
73
|
+
};
|
|
74
|
+
const { ctx, channelsDir } = await buildMockChannelContext({
|
|
75
|
+
channelFiles: [{ name: 'general.md', content: '# General channel' }],
|
|
76
|
+
});
|
|
77
|
+
ctx.byChannelId = new Map([['chan', { channelId: 'chan', channelName: 'general', contextPath: path.join(channelsDir, 'general.md') }]]);
|
|
78
|
+
const handler = createMessageCreateHandler({
|
|
79
|
+
allowUserIds: new Set(['123']),
|
|
80
|
+
runtime,
|
|
81
|
+
sessionManager: { getOrCreate: vi.fn(async () => 'sess') },
|
|
82
|
+
workspaceCwd: '/tmp',
|
|
83
|
+
projectCwd: '/tmp',
|
|
84
|
+
groupsDir: '/tmp',
|
|
85
|
+
useGroupDirCwd: false,
|
|
86
|
+
runtimeModel: 'opus',
|
|
87
|
+
runtimeTools: [],
|
|
88
|
+
runtimeTimeoutMs: 1000,
|
|
89
|
+
requireChannelContext: true,
|
|
90
|
+
autoIndexChannelContext: false,
|
|
91
|
+
autoJoinThreads: false,
|
|
92
|
+
useRuntimeSessions: true,
|
|
93
|
+
discordChannelContext: ctx,
|
|
94
|
+
discordActionsEnabled: false,
|
|
95
|
+
discordActionsChannels: true,
|
|
96
|
+
discordActionsMessaging: false,
|
|
97
|
+
discordActionsGuild: false,
|
|
98
|
+
discordActionsModeration: false,
|
|
99
|
+
discordActionsPolls: false,
|
|
100
|
+
discordActionsTasks: false,
|
|
101
|
+
discordActionsBotProfile: false,
|
|
102
|
+
messageHistoryBudget: 0,
|
|
103
|
+
summaryEnabled: false,
|
|
104
|
+
summaryModel: 'haiku',
|
|
105
|
+
summaryMaxChars: 2000,
|
|
106
|
+
summaryEveryNTurns: 5,
|
|
107
|
+
summaryDataDir: '/tmp/summaries',
|
|
108
|
+
summaryToDurableEnabled: false,
|
|
109
|
+
shortTermMemoryEnabled: false,
|
|
110
|
+
shortTermDataDir: '/tmp/shortterm',
|
|
111
|
+
shortTermMaxEntries: 20,
|
|
112
|
+
shortTermMaxAgeMs: 21600000,
|
|
113
|
+
shortTermInjectMaxChars: 1000,
|
|
114
|
+
durableMemoryEnabled: false,
|
|
115
|
+
durableDataDir: '/tmp/durable',
|
|
116
|
+
durableInjectMaxChars: 2000,
|
|
117
|
+
durableMaxItems: 200,
|
|
118
|
+
memoryCommandsEnabled: false,
|
|
119
|
+
actionFollowupDepth: 0,
|
|
120
|
+
reactionHandlerEnabled: false,
|
|
121
|
+
reactionRemoveHandlerEnabled: false,
|
|
122
|
+
reactionMaxAgeMs: 86400000,
|
|
123
|
+
streamStallWarningMs: 0,
|
|
124
|
+
botDisplayName: 'TestBot',
|
|
125
|
+
}, queue);
|
|
126
|
+
await handler(makeMsg({ channelId: 'chan' }));
|
|
127
|
+
expect(runtime.invoke).toHaveBeenCalled();
|
|
128
|
+
// PA file contents should be inlined (pa-safety.md retired — not loaded at runtime).
|
|
129
|
+
expect(seenPrompt).toContain('# PA context');
|
|
130
|
+
expect(seenPrompt).not.toContain('# PA safety rules');
|
|
131
|
+
expect(seenPrompt).toContain('# General channel');
|
|
132
|
+
expect(seenPrompt).not.toContain('Context files (read with Read tool');
|
|
133
|
+
expect(seenPrompt).toContain('User message:\nhello');
|
|
134
|
+
// Boundary instruction appears between context sections and user message.
|
|
135
|
+
const boundaryIdx = seenPrompt.indexOf('internal system context');
|
|
136
|
+
const userMsgIdx = seenPrompt.indexOf('User message:');
|
|
137
|
+
const paIdx = seenPrompt.indexOf('# PA context');
|
|
138
|
+
expect(boundaryIdx).toBeGreaterThan(-1);
|
|
139
|
+
expect(boundaryIdx).toBeLessThan(userMsgIdx);
|
|
140
|
+
expect(boundaryIdx).toBeGreaterThan(paIdx);
|
|
141
|
+
});
|
|
142
|
+
it('thread uses parent channel context file', async () => {
|
|
143
|
+
const queue = makeQueue();
|
|
144
|
+
let seenPrompt = '';
|
|
145
|
+
const runtime = {
|
|
146
|
+
invoke: vi.fn(async function* (p) {
|
|
147
|
+
seenPrompt = p.prompt;
|
|
148
|
+
yield { type: 'text_final', text: 'ok' };
|
|
149
|
+
}),
|
|
150
|
+
};
|
|
151
|
+
const { ctx, channelsDir } = await buildMockChannelContext({
|
|
152
|
+
channelFiles: [{ name: 'general.md', content: '# General channel context' }],
|
|
153
|
+
});
|
|
154
|
+
ctx.byChannelId = new Map([['parent', { channelId: 'parent', channelName: 'general', contextPath: path.join(channelsDir, 'general.md') }]]);
|
|
155
|
+
const handler = createMessageCreateHandler({
|
|
156
|
+
allowUserIds: new Set(['123']),
|
|
157
|
+
runtime,
|
|
158
|
+
sessionManager: { getOrCreate: vi.fn(async () => 'sess') },
|
|
159
|
+
workspaceCwd: '/tmp',
|
|
160
|
+
projectCwd: '/tmp',
|
|
161
|
+
groupsDir: '/tmp',
|
|
162
|
+
useGroupDirCwd: false,
|
|
163
|
+
runtimeModel: 'opus',
|
|
164
|
+
runtimeTools: [],
|
|
165
|
+
runtimeTimeoutMs: 1000,
|
|
166
|
+
requireChannelContext: true,
|
|
167
|
+
autoIndexChannelContext: false,
|
|
168
|
+
autoJoinThreads: false,
|
|
169
|
+
useRuntimeSessions: true,
|
|
170
|
+
discordChannelContext: ctx,
|
|
171
|
+
discordActionsEnabled: false,
|
|
172
|
+
discordActionsChannels: true,
|
|
173
|
+
discordActionsMessaging: false,
|
|
174
|
+
discordActionsGuild: false,
|
|
175
|
+
discordActionsModeration: false,
|
|
176
|
+
discordActionsPolls: false,
|
|
177
|
+
discordActionsTasks: false,
|
|
178
|
+
discordActionsBotProfile: false,
|
|
179
|
+
messageHistoryBudget: 0,
|
|
180
|
+
summaryEnabled: false,
|
|
181
|
+
summaryModel: 'haiku',
|
|
182
|
+
summaryMaxChars: 2000,
|
|
183
|
+
summaryEveryNTurns: 5,
|
|
184
|
+
summaryDataDir: '/tmp/summaries',
|
|
185
|
+
summaryToDurableEnabled: false,
|
|
186
|
+
shortTermMemoryEnabled: false,
|
|
187
|
+
shortTermDataDir: '/tmp/shortterm',
|
|
188
|
+
shortTermMaxEntries: 20,
|
|
189
|
+
shortTermMaxAgeMs: 21600000,
|
|
190
|
+
shortTermInjectMaxChars: 1000,
|
|
191
|
+
durableMemoryEnabled: false,
|
|
192
|
+
durableDataDir: '/tmp/durable',
|
|
193
|
+
durableInjectMaxChars: 2000,
|
|
194
|
+
durableMaxItems: 200,
|
|
195
|
+
memoryCommandsEnabled: false,
|
|
196
|
+
actionFollowupDepth: 0,
|
|
197
|
+
reactionHandlerEnabled: false,
|
|
198
|
+
reactionRemoveHandlerEnabled: false,
|
|
199
|
+
reactionMaxAgeMs: 86400000,
|
|
200
|
+
streamStallWarningMs: 0,
|
|
201
|
+
botDisplayName: 'TestBot',
|
|
202
|
+
}, queue);
|
|
203
|
+
await handler(makeMsg({
|
|
204
|
+
channelId: 'thread',
|
|
205
|
+
channel: { send: vi.fn(async () => { }), isThread: () => true, parentId: 'parent', name: 'thread-name', id: 'thread' },
|
|
206
|
+
}));
|
|
207
|
+
expect(runtime.invoke).toHaveBeenCalled();
|
|
208
|
+
expect(seenPrompt).toContain('# General channel context');
|
|
209
|
+
});
|
|
210
|
+
it('DM uses dm context file', async () => {
|
|
211
|
+
const queue = makeQueue();
|
|
212
|
+
let seenPrompt = '';
|
|
213
|
+
const runtime = {
|
|
214
|
+
invoke: vi.fn(async function* (p) {
|
|
215
|
+
seenPrompt = p.prompt;
|
|
216
|
+
yield { type: 'text_final', text: 'ok' };
|
|
217
|
+
}),
|
|
218
|
+
};
|
|
219
|
+
const { ctx, channelsDir } = await buildMockChannelContext({
|
|
220
|
+
channelFiles: [{ name: 'dm.md', content: '# DM channel context' }],
|
|
221
|
+
});
|
|
222
|
+
ctx.dmContextPath = path.join(channelsDir, 'dm.md');
|
|
223
|
+
const handler = createMessageCreateHandler({
|
|
224
|
+
allowUserIds: new Set(['123']),
|
|
225
|
+
runtime,
|
|
226
|
+
sessionManager: { getOrCreate: vi.fn(async () => 'sess') },
|
|
227
|
+
workspaceCwd: '/tmp',
|
|
228
|
+
projectCwd: '/tmp',
|
|
229
|
+
groupsDir: '/tmp',
|
|
230
|
+
useGroupDirCwd: false,
|
|
231
|
+
runtimeModel: 'opus',
|
|
232
|
+
runtimeTools: [],
|
|
233
|
+
runtimeTimeoutMs: 1000,
|
|
234
|
+
requireChannelContext: true,
|
|
235
|
+
autoIndexChannelContext: false,
|
|
236
|
+
autoJoinThreads: false,
|
|
237
|
+
useRuntimeSessions: true,
|
|
238
|
+
discordChannelContext: ctx,
|
|
239
|
+
discordActionsEnabled: false,
|
|
240
|
+
discordActionsChannels: true,
|
|
241
|
+
discordActionsMessaging: false,
|
|
242
|
+
discordActionsGuild: false,
|
|
243
|
+
discordActionsModeration: false,
|
|
244
|
+
discordActionsPolls: false,
|
|
245
|
+
discordActionsTasks: false,
|
|
246
|
+
discordActionsBotProfile: false,
|
|
247
|
+
messageHistoryBudget: 0,
|
|
248
|
+
summaryEnabled: false,
|
|
249
|
+
summaryModel: 'haiku',
|
|
250
|
+
summaryMaxChars: 2000,
|
|
251
|
+
summaryEveryNTurns: 5,
|
|
252
|
+
summaryDataDir: '/tmp/summaries',
|
|
253
|
+
summaryToDurableEnabled: false,
|
|
254
|
+
shortTermMemoryEnabled: false,
|
|
255
|
+
shortTermDataDir: '/tmp/shortterm',
|
|
256
|
+
shortTermMaxEntries: 20,
|
|
257
|
+
shortTermMaxAgeMs: 21600000,
|
|
258
|
+
shortTermInjectMaxChars: 1000,
|
|
259
|
+
durableMemoryEnabled: false,
|
|
260
|
+
durableDataDir: '/tmp/durable',
|
|
261
|
+
durableInjectMaxChars: 2000,
|
|
262
|
+
durableMaxItems: 200,
|
|
263
|
+
memoryCommandsEnabled: false,
|
|
264
|
+
actionFollowupDepth: 0,
|
|
265
|
+
reactionHandlerEnabled: false,
|
|
266
|
+
reactionRemoveHandlerEnabled: false,
|
|
267
|
+
reactionMaxAgeMs: 86400000,
|
|
268
|
+
streamStallWarningMs: 0,
|
|
269
|
+
botDisplayName: 'TestBot',
|
|
270
|
+
}, queue);
|
|
271
|
+
await handler(makeMsg({ guildId: null, channelId: 'dmchan' }));
|
|
272
|
+
expect(runtime.invoke).toHaveBeenCalled();
|
|
273
|
+
expect(seenPrompt).toContain('# DM channel context');
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
describe('discord action flags are not frozen at handler creation', () => {
|
|
277
|
+
it('tasks prompt section appears after toggling discordActionsTasks on the same params object', async () => {
|
|
278
|
+
const queue = makeQueue();
|
|
279
|
+
const prompts = [];
|
|
280
|
+
const runtime = {
|
|
281
|
+
invoke: vi.fn(async function* (p) {
|
|
282
|
+
prompts.push(p.prompt);
|
|
283
|
+
yield { type: 'text_final', text: 'ok' };
|
|
284
|
+
}),
|
|
285
|
+
};
|
|
286
|
+
const params = {
|
|
287
|
+
allowUserIds: new Set(['123']),
|
|
288
|
+
runtime,
|
|
289
|
+
sessionManager: { getOrCreate: vi.fn(async () => 'sess') },
|
|
290
|
+
workspaceCwd: '/tmp',
|
|
291
|
+
projectCwd: '/tmp',
|
|
292
|
+
groupsDir: '/tmp',
|
|
293
|
+
useGroupDirCwd: false,
|
|
294
|
+
runtimeModel: 'opus',
|
|
295
|
+
runtimeTools: [],
|
|
296
|
+
runtimeTimeoutMs: 1000,
|
|
297
|
+
requireChannelContext: false,
|
|
298
|
+
autoIndexChannelContext: false,
|
|
299
|
+
autoJoinThreads: false,
|
|
300
|
+
useRuntimeSessions: true,
|
|
301
|
+
discordActionsEnabled: true,
|
|
302
|
+
discordActionsChannels: false,
|
|
303
|
+
discordActionsMessaging: false,
|
|
304
|
+
discordActionsGuild: false,
|
|
305
|
+
discordActionsModeration: false,
|
|
306
|
+
discordActionsPolls: false,
|
|
307
|
+
discordActionsTasks: false,
|
|
308
|
+
discordActionsBotProfile: false,
|
|
309
|
+
messageHistoryBudget: 0,
|
|
310
|
+
summaryEnabled: false,
|
|
311
|
+
summaryModel: 'haiku',
|
|
312
|
+
summaryMaxChars: 2000,
|
|
313
|
+
summaryEveryNTurns: 5,
|
|
314
|
+
summaryDataDir: '/tmp/summaries',
|
|
315
|
+
summaryToDurableEnabled: false,
|
|
316
|
+
shortTermMemoryEnabled: false,
|
|
317
|
+
shortTermDataDir: '/tmp/shortterm',
|
|
318
|
+
shortTermMaxEntries: 20,
|
|
319
|
+
shortTermMaxAgeMs: 21600000,
|
|
320
|
+
shortTermInjectMaxChars: 1000,
|
|
321
|
+
durableMemoryEnabled: false,
|
|
322
|
+
durableDataDir: '/tmp/durable',
|
|
323
|
+
durableInjectMaxChars: 2000,
|
|
324
|
+
durableMaxItems: 200,
|
|
325
|
+
memoryCommandsEnabled: false,
|
|
326
|
+
actionFollowupDepth: 0,
|
|
327
|
+
reactionHandlerEnabled: false,
|
|
328
|
+
reactionRemoveHandlerEnabled: false,
|
|
329
|
+
reactionMaxAgeMs: 86400000,
|
|
330
|
+
streamStallWarningMs: 0,
|
|
331
|
+
botDisplayName: 'TestBot',
|
|
332
|
+
};
|
|
333
|
+
const handler = createMessageCreateHandler(params, queue);
|
|
334
|
+
await handler(makeMsg({ channelId: 'chan', content: 'first' }));
|
|
335
|
+
expect(prompts[0]).toContain('## Discord Actions');
|
|
336
|
+
expect(prompts[0]).not.toContain('taskCreate');
|
|
337
|
+
params.discordActionsTasks = true;
|
|
338
|
+
await handler(makeMsg({ channelId: 'chan', content: 'second' }));
|
|
339
|
+
expect(prompts[1]).toContain('## Discord Actions');
|
|
340
|
+
expect(prompts[1]).toContain('taskCreate');
|
|
341
|
+
});
|
|
342
|
+
});
|
|
343
|
+
describe('durable memory injection into prompt', () => {
|
|
344
|
+
it('injects durable section when enabled and store has items', async () => {
|
|
345
|
+
const queue = makeQueue();
|
|
346
|
+
let seenPrompt = '';
|
|
347
|
+
const runtime = {
|
|
348
|
+
invoke: vi.fn(async function* (p) {
|
|
349
|
+
seenPrompt = p.prompt;
|
|
350
|
+
yield { type: 'text_final', text: 'ok' };
|
|
351
|
+
}),
|
|
352
|
+
};
|
|
353
|
+
// Seed a durable memory file on disk.
|
|
354
|
+
const durableDir = await fs.mkdtemp(path.join(os.tmpdir(), 'durable-integration-'));
|
|
355
|
+
const store = { version: 1, updatedAt: 0, items: [] };
|
|
356
|
+
addItem(store, 'User prefers TypeScript', { type: 'manual' }, 200);
|
|
357
|
+
await saveDurableMemory(durableDir, '123', store);
|
|
358
|
+
const handler = createMessageCreateHandler({
|
|
359
|
+
allowUserIds: new Set(['123']),
|
|
360
|
+
runtime,
|
|
361
|
+
sessionManager: { getOrCreate: vi.fn(async () => 'sess') },
|
|
362
|
+
workspaceCwd: '/tmp',
|
|
363
|
+
projectCwd: '/tmp',
|
|
364
|
+
groupsDir: '/tmp',
|
|
365
|
+
useGroupDirCwd: false,
|
|
366
|
+
runtimeModel: 'opus',
|
|
367
|
+
runtimeTools: [],
|
|
368
|
+
runtimeTimeoutMs: 1000,
|
|
369
|
+
requireChannelContext: false,
|
|
370
|
+
autoIndexChannelContext: false,
|
|
371
|
+
autoJoinThreads: false,
|
|
372
|
+
useRuntimeSessions: true,
|
|
373
|
+
discordActionsEnabled: false,
|
|
374
|
+
discordActionsChannels: true,
|
|
375
|
+
discordActionsMessaging: false,
|
|
376
|
+
discordActionsGuild: false,
|
|
377
|
+
discordActionsModeration: false,
|
|
378
|
+
discordActionsPolls: false,
|
|
379
|
+
discordActionsTasks: false,
|
|
380
|
+
discordActionsBotProfile: false,
|
|
381
|
+
messageHistoryBudget: 0,
|
|
382
|
+
summaryEnabled: false,
|
|
383
|
+
summaryModel: 'haiku',
|
|
384
|
+
summaryMaxChars: 2000,
|
|
385
|
+
summaryEveryNTurns: 5,
|
|
386
|
+
summaryDataDir: '/tmp/summaries',
|
|
387
|
+
summaryToDurableEnabled: false,
|
|
388
|
+
shortTermMemoryEnabled: false,
|
|
389
|
+
shortTermDataDir: '/tmp/shortterm',
|
|
390
|
+
shortTermMaxEntries: 20,
|
|
391
|
+
shortTermMaxAgeMs: 21600000,
|
|
392
|
+
shortTermInjectMaxChars: 1000,
|
|
393
|
+
durableMemoryEnabled: true,
|
|
394
|
+
durableDataDir: durableDir,
|
|
395
|
+
durableInjectMaxChars: 2000,
|
|
396
|
+
durableMaxItems: 200,
|
|
397
|
+
memoryCommandsEnabled: false,
|
|
398
|
+
actionFollowupDepth: 0,
|
|
399
|
+
reactionHandlerEnabled: false,
|
|
400
|
+
reactionRemoveHandlerEnabled: false,
|
|
401
|
+
reactionMaxAgeMs: 86400000,
|
|
402
|
+
streamStallWarningMs: 0,
|
|
403
|
+
botDisplayName: 'TestBot',
|
|
404
|
+
}, queue);
|
|
405
|
+
await handler(makeMsg({ guildId: null, channelId: 'dmchan' }));
|
|
406
|
+
expect(runtime.invoke).toHaveBeenCalled();
|
|
407
|
+
expect(seenPrompt).toContain('Durable memory (user-specific notes):');
|
|
408
|
+
expect(seenPrompt).toContain('[fact] User prefers TypeScript');
|
|
409
|
+
});
|
|
410
|
+
});
|
|
411
|
+
describe('workspace PA files in prompt', () => {
|
|
412
|
+
it('injects SOUL, IDENTITY, USER before PA context when files exist', async () => {
|
|
413
|
+
const queue = makeQueue();
|
|
414
|
+
let seenPrompt = '';
|
|
415
|
+
const runtime = {
|
|
416
|
+
invoke: vi.fn(async function* (p) {
|
|
417
|
+
seenPrompt = p.prompt;
|
|
418
|
+
yield { type: 'text_final', text: 'ok' };
|
|
419
|
+
}),
|
|
420
|
+
};
|
|
421
|
+
// Create a temp workspace with PA files.
|
|
422
|
+
const workspace = await fs.mkdtemp(path.join(os.tmpdir(), 'pa-prompt-'));
|
|
423
|
+
await fs.writeFile(path.join(workspace, 'SOUL.md'), '# Soul', 'utf-8');
|
|
424
|
+
await fs.writeFile(path.join(workspace, 'IDENTITY.md'), '# Identity', 'utf-8');
|
|
425
|
+
await fs.writeFile(path.join(workspace, 'USER.md'), '# User', 'utf-8');
|
|
426
|
+
const { ctx, channelsDir } = await buildMockChannelContext({
|
|
427
|
+
channelFiles: [{ name: 'general.md', content: '# General' }],
|
|
428
|
+
});
|
|
429
|
+
ctx.byChannelId = new Map([['chan', { channelId: 'chan', channelName: 'general', contextPath: path.join(channelsDir, 'general.md') }]]);
|
|
430
|
+
const handler = createMessageCreateHandler({
|
|
431
|
+
allowUserIds: new Set(['123']),
|
|
432
|
+
runtime,
|
|
433
|
+
sessionManager: { getOrCreate: vi.fn(async () => 'sess') },
|
|
434
|
+
workspaceCwd: workspace,
|
|
435
|
+
projectCwd: '/tmp',
|
|
436
|
+
groupsDir: '/tmp',
|
|
437
|
+
useGroupDirCwd: false,
|
|
438
|
+
runtimeModel: 'opus',
|
|
439
|
+
runtimeTools: [],
|
|
440
|
+
runtimeTimeoutMs: 1000,
|
|
441
|
+
requireChannelContext: false,
|
|
442
|
+
autoIndexChannelContext: false,
|
|
443
|
+
autoJoinThreads: false,
|
|
444
|
+
useRuntimeSessions: true,
|
|
445
|
+
discordChannelContext: ctx,
|
|
446
|
+
discordActionsEnabled: false,
|
|
447
|
+
discordActionsChannels: true,
|
|
448
|
+
discordActionsMessaging: false,
|
|
449
|
+
discordActionsGuild: false,
|
|
450
|
+
discordActionsModeration: false,
|
|
451
|
+
discordActionsPolls: false,
|
|
452
|
+
discordActionsTasks: false,
|
|
453
|
+
discordActionsBotProfile: false,
|
|
454
|
+
messageHistoryBudget: 0,
|
|
455
|
+
summaryEnabled: false,
|
|
456
|
+
summaryModel: 'haiku',
|
|
457
|
+
summaryMaxChars: 2000,
|
|
458
|
+
summaryEveryNTurns: 5,
|
|
459
|
+
summaryDataDir: '/tmp/summaries',
|
|
460
|
+
summaryToDurableEnabled: false,
|
|
461
|
+
shortTermMemoryEnabled: false,
|
|
462
|
+
shortTermDataDir: '/tmp/shortterm',
|
|
463
|
+
shortTermMaxEntries: 20,
|
|
464
|
+
shortTermMaxAgeMs: 21600000,
|
|
465
|
+
shortTermInjectMaxChars: 1000,
|
|
466
|
+
durableMemoryEnabled: false,
|
|
467
|
+
durableDataDir: '/tmp/durable',
|
|
468
|
+
durableInjectMaxChars: 2000,
|
|
469
|
+
durableMaxItems: 200,
|
|
470
|
+
memoryCommandsEnabled: false,
|
|
471
|
+
actionFollowupDepth: 0,
|
|
472
|
+
reactionHandlerEnabled: false,
|
|
473
|
+
reactionRemoveHandlerEnabled: false,
|
|
474
|
+
reactionMaxAgeMs: 86400000,
|
|
475
|
+
streamStallWarningMs: 0,
|
|
476
|
+
botDisplayName: 'TestBot',
|
|
477
|
+
}, queue);
|
|
478
|
+
await handler(makeMsg({ channelId: 'chan' }));
|
|
479
|
+
expect(runtime.invoke).toHaveBeenCalled();
|
|
480
|
+
// PA file contents should be inlined before PA context module contents.
|
|
481
|
+
const soulIdx = seenPrompt.indexOf('# Soul');
|
|
482
|
+
const identIdx = seenPrompt.indexOf('# Identity');
|
|
483
|
+
const userIdx = seenPrompt.indexOf('# User');
|
|
484
|
+
const paIdx = seenPrompt.indexOf('--- pa.md ---');
|
|
485
|
+
expect(soulIdx).toBeGreaterThan(-1);
|
|
486
|
+
expect(identIdx).toBeGreaterThan(-1);
|
|
487
|
+
expect(userIdx).toBeGreaterThan(-1);
|
|
488
|
+
expect(soulIdx).toBeLessThan(paIdx);
|
|
489
|
+
expect(identIdx).toBeLessThan(paIdx);
|
|
490
|
+
expect(userIdx).toBeLessThan(paIdx);
|
|
491
|
+
});
|
|
492
|
+
it('intercepts with onboarding flow when IDENTITY.md has template marker', async () => {
|
|
493
|
+
const queue = makeQueue();
|
|
494
|
+
const runtime = {
|
|
495
|
+
invoke: vi.fn(async function* (p) {
|
|
496
|
+
yield { type: 'text_final', text: 'ok' };
|
|
497
|
+
}),
|
|
498
|
+
};
|
|
499
|
+
const workspace = await fs.mkdtemp(path.join(os.tmpdir(), 'pa-prompt-'));
|
|
500
|
+
await fs.writeFile(path.join(workspace, 'BOOTSTRAP.md'), '# Bootstrap', 'utf-8');
|
|
501
|
+
await fs.writeFile(path.join(workspace, 'SOUL.md'), '# Soul', 'utf-8');
|
|
502
|
+
// IDENTITY.md must contain template marker so onboarding is incomplete.
|
|
503
|
+
await fs.writeFile(path.join(workspace, 'IDENTITY.md'), '# Identity\n*(pick something you like)*', 'utf-8');
|
|
504
|
+
await fs.writeFile(path.join(workspace, 'USER.md'), '# User', 'utf-8');
|
|
505
|
+
const handler = createMessageCreateHandler({
|
|
506
|
+
allowUserIds: new Set(['123']),
|
|
507
|
+
runtime,
|
|
508
|
+
sessionManager: { getOrCreate: vi.fn(async () => 'sess') },
|
|
509
|
+
workspaceCwd: workspace,
|
|
510
|
+
projectCwd: '/tmp',
|
|
511
|
+
groupsDir: '/tmp',
|
|
512
|
+
useGroupDirCwd: false,
|
|
513
|
+
runtimeModel: 'opus',
|
|
514
|
+
runtimeTools: [],
|
|
515
|
+
runtimeTimeoutMs: 1000,
|
|
516
|
+
requireChannelContext: false,
|
|
517
|
+
autoIndexChannelContext: false,
|
|
518
|
+
autoJoinThreads: false,
|
|
519
|
+
useRuntimeSessions: true,
|
|
520
|
+
discordActionsEnabled: false,
|
|
521
|
+
discordActionsChannels: true,
|
|
522
|
+
discordActionsMessaging: false,
|
|
523
|
+
discordActionsGuild: false,
|
|
524
|
+
discordActionsModeration: false,
|
|
525
|
+
discordActionsPolls: false,
|
|
526
|
+
discordActionsTasks: false,
|
|
527
|
+
discordActionsBotProfile: false,
|
|
528
|
+
messageHistoryBudget: 0,
|
|
529
|
+
summaryEnabled: false,
|
|
530
|
+
summaryModel: 'haiku',
|
|
531
|
+
summaryMaxChars: 2000,
|
|
532
|
+
summaryEveryNTurns: 5,
|
|
533
|
+
summaryDataDir: '/tmp/summaries',
|
|
534
|
+
summaryToDurableEnabled: false,
|
|
535
|
+
shortTermMemoryEnabled: false,
|
|
536
|
+
shortTermDataDir: '/tmp/shortterm',
|
|
537
|
+
shortTermMaxEntries: 20,
|
|
538
|
+
shortTermMaxAgeMs: 21600000,
|
|
539
|
+
shortTermInjectMaxChars: 1000,
|
|
540
|
+
durableMemoryEnabled: false,
|
|
541
|
+
durableDataDir: '/tmp/durable',
|
|
542
|
+
durableInjectMaxChars: 2000,
|
|
543
|
+
durableMaxItems: 200,
|
|
544
|
+
memoryCommandsEnabled: false,
|
|
545
|
+
actionFollowupDepth: 0,
|
|
546
|
+
reactionHandlerEnabled: false,
|
|
547
|
+
reactionRemoveHandlerEnabled: false,
|
|
548
|
+
reactionMaxAgeMs: 86400000,
|
|
549
|
+
streamStallWarningMs: 0,
|
|
550
|
+
botDisplayName: 'TestBot',
|
|
551
|
+
}, queue);
|
|
552
|
+
await handler(makeMsg({ guildId: null, channelId: 'dmchan' }));
|
|
553
|
+
// When onboarding is incomplete, the onboarding flow intercepts the message
|
|
554
|
+
// and the runtime is NOT invoked.
|
|
555
|
+
expect(runtime.invoke).not.toHaveBeenCalled();
|
|
556
|
+
});
|
|
557
|
+
it('includes TOOLS.md when present', async () => {
|
|
558
|
+
const queue = makeQueue();
|
|
559
|
+
let seenPrompt = '';
|
|
560
|
+
const runtime = {
|
|
561
|
+
invoke: vi.fn(async function* (p) {
|
|
562
|
+
seenPrompt = p.prompt;
|
|
563
|
+
yield { type: 'text_final', text: 'ok' };
|
|
564
|
+
}),
|
|
565
|
+
};
|
|
566
|
+
const workspace = await fs.mkdtemp(path.join(os.tmpdir(), 'pa-prompt-'));
|
|
567
|
+
await fs.writeFile(path.join(workspace, 'SOUL.md'), '# Soul', 'utf-8');
|
|
568
|
+
await fs.writeFile(path.join(workspace, 'TOOLS.md'), '# Tools', 'utf-8');
|
|
569
|
+
const handler = createMessageCreateHandler({
|
|
570
|
+
allowUserIds: new Set(['123']),
|
|
571
|
+
runtime,
|
|
572
|
+
sessionManager: { getOrCreate: vi.fn(async () => 'sess') },
|
|
573
|
+
workspaceCwd: workspace,
|
|
574
|
+
projectCwd: '/tmp',
|
|
575
|
+
groupsDir: '/tmp',
|
|
576
|
+
useGroupDirCwd: false,
|
|
577
|
+
runtimeModel: 'opus',
|
|
578
|
+
runtimeTools: [],
|
|
579
|
+
runtimeTimeoutMs: 1000,
|
|
580
|
+
requireChannelContext: false,
|
|
581
|
+
autoIndexChannelContext: false,
|
|
582
|
+
autoJoinThreads: false,
|
|
583
|
+
useRuntimeSessions: true,
|
|
584
|
+
discordActionsEnabled: false,
|
|
585
|
+
discordActionsChannels: true,
|
|
586
|
+
discordActionsMessaging: false,
|
|
587
|
+
discordActionsGuild: false,
|
|
588
|
+
discordActionsModeration: false,
|
|
589
|
+
discordActionsPolls: false,
|
|
590
|
+
discordActionsTasks: false,
|
|
591
|
+
discordActionsBotProfile: false,
|
|
592
|
+
messageHistoryBudget: 0,
|
|
593
|
+
summaryEnabled: false,
|
|
594
|
+
summaryModel: 'haiku',
|
|
595
|
+
summaryMaxChars: 2000,
|
|
596
|
+
summaryEveryNTurns: 5,
|
|
597
|
+
summaryDataDir: '/tmp/summaries',
|
|
598
|
+
summaryToDurableEnabled: false,
|
|
599
|
+
shortTermMemoryEnabled: false,
|
|
600
|
+
shortTermDataDir: '/tmp/shortterm',
|
|
601
|
+
shortTermMaxEntries: 20,
|
|
602
|
+
shortTermMaxAgeMs: 21600000,
|
|
603
|
+
shortTermInjectMaxChars: 1000,
|
|
604
|
+
durableMemoryEnabled: false,
|
|
605
|
+
durableDataDir: '/tmp/durable',
|
|
606
|
+
durableInjectMaxChars: 2000,
|
|
607
|
+
durableMaxItems: 200,
|
|
608
|
+
memoryCommandsEnabled: false,
|
|
609
|
+
actionFollowupDepth: 0,
|
|
610
|
+
reactionHandlerEnabled: false,
|
|
611
|
+
reactionRemoveHandlerEnabled: false,
|
|
612
|
+
reactionMaxAgeMs: 86400000,
|
|
613
|
+
streamStallWarningMs: 0,
|
|
614
|
+
botDisplayName: 'TestBot',
|
|
615
|
+
}, queue);
|
|
616
|
+
await handler(makeMsg({ guildId: null, channelId: 'dmchan' }));
|
|
617
|
+
expect(runtime.invoke).toHaveBeenCalled();
|
|
618
|
+
expect(seenPrompt).toContain('TOOLS.md');
|
|
619
|
+
});
|
|
620
|
+
});
|
|
621
|
+
describe('memory command interception', () => {
|
|
622
|
+
it('!memory show returns early without invoking runtime', async () => {
|
|
623
|
+
const queue = makeQueue();
|
|
624
|
+
const runtime = {
|
|
625
|
+
invoke: vi.fn(async function* () {
|
|
626
|
+
yield { type: 'text_final', text: 'should not run' };
|
|
627
|
+
}),
|
|
628
|
+
};
|
|
629
|
+
const sessionManager = { getOrCreate: vi.fn(async () => 'sess') };
|
|
630
|
+
const durableDir = await fs.mkdtemp(path.join(os.tmpdir(), 'cmd-integration-'));
|
|
631
|
+
const summaryDir = await fs.mkdtemp(path.join(os.tmpdir(), 'cmd-summary-'));
|
|
632
|
+
const handler = createMessageCreateHandler({
|
|
633
|
+
allowUserIds: new Set(['123']),
|
|
634
|
+
runtime,
|
|
635
|
+
sessionManager,
|
|
636
|
+
workspaceCwd: '/tmp',
|
|
637
|
+
projectCwd: '/tmp',
|
|
638
|
+
groupsDir: '/tmp',
|
|
639
|
+
useGroupDirCwd: false,
|
|
640
|
+
runtimeModel: 'opus',
|
|
641
|
+
runtimeTools: [],
|
|
642
|
+
runtimeTimeoutMs: 1000,
|
|
643
|
+
requireChannelContext: false,
|
|
644
|
+
autoIndexChannelContext: false,
|
|
645
|
+
autoJoinThreads: false,
|
|
646
|
+
useRuntimeSessions: true,
|
|
647
|
+
discordActionsEnabled: false,
|
|
648
|
+
discordActionsChannels: true,
|
|
649
|
+
discordActionsMessaging: false,
|
|
650
|
+
discordActionsGuild: false,
|
|
651
|
+
discordActionsModeration: false,
|
|
652
|
+
discordActionsPolls: false,
|
|
653
|
+
discordActionsTasks: false,
|
|
654
|
+
discordActionsBotProfile: false,
|
|
655
|
+
messageHistoryBudget: 0,
|
|
656
|
+
summaryEnabled: false,
|
|
657
|
+
summaryModel: 'haiku',
|
|
658
|
+
summaryMaxChars: 2000,
|
|
659
|
+
summaryEveryNTurns: 5,
|
|
660
|
+
summaryDataDir: summaryDir,
|
|
661
|
+
summaryToDurableEnabled: false,
|
|
662
|
+
shortTermMemoryEnabled: false,
|
|
663
|
+
shortTermDataDir: '/tmp/shortterm',
|
|
664
|
+
shortTermMaxEntries: 20,
|
|
665
|
+
shortTermMaxAgeMs: 21600000,
|
|
666
|
+
shortTermInjectMaxChars: 1000,
|
|
667
|
+
durableMemoryEnabled: true,
|
|
668
|
+
durableDataDir: durableDir,
|
|
669
|
+
durableInjectMaxChars: 2000,
|
|
670
|
+
durableMaxItems: 200,
|
|
671
|
+
memoryCommandsEnabled: true,
|
|
672
|
+
actionFollowupDepth: 0,
|
|
673
|
+
reactionHandlerEnabled: false,
|
|
674
|
+
reactionRemoveHandlerEnabled: false,
|
|
675
|
+
reactionMaxAgeMs: 86400000,
|
|
676
|
+
streamStallWarningMs: 0,
|
|
677
|
+
botDisplayName: 'TestBot',
|
|
678
|
+
}, queue);
|
|
679
|
+
const msg = makeMsg({ guildId: null, channelId: 'dmchan', content: '!memory show' });
|
|
680
|
+
await handler(msg);
|
|
681
|
+
expect(runtime.invoke).not.toHaveBeenCalled();
|
|
682
|
+
expect(sessionManager.getOrCreate).not.toHaveBeenCalled();
|
|
683
|
+
expect(msg.reply).toHaveBeenCalledWith(expect.objectContaining({
|
|
684
|
+
content: expect.stringContaining('Durable memory:'),
|
|
685
|
+
allowedMentions: { parse: [] },
|
|
686
|
+
}));
|
|
687
|
+
});
|
|
688
|
+
});
|
|
689
|
+
// ---------------------------------------------------------------------------
|
|
690
|
+
// Bead context injection into prompt
|
|
691
|
+
// ---------------------------------------------------------------------------
|
|
692
|
+
const BEAD_FORUM_ID = '11112222333344445555';
|
|
693
|
+
function makeBeadParams(overrides) {
|
|
694
|
+
const queue = makeQueue();
|
|
695
|
+
let seenPrompt = '';
|
|
696
|
+
const runtime = {
|
|
697
|
+
invoke: vi.fn(async function* (p) {
|
|
698
|
+
seenPrompt = p.prompt;
|
|
699
|
+
yield { type: 'text_final', text: 'ok' };
|
|
700
|
+
}),
|
|
701
|
+
};
|
|
702
|
+
const params = {
|
|
703
|
+
allowUserIds: new Set(['123']),
|
|
704
|
+
runtime,
|
|
705
|
+
sessionManager: { getOrCreate: vi.fn(async () => 'sess') },
|
|
706
|
+
workspaceCwd: '/tmp',
|
|
707
|
+
projectCwd: '/tmp',
|
|
708
|
+
groupsDir: '/tmp',
|
|
709
|
+
useGroupDirCwd: false,
|
|
710
|
+
runtimeModel: 'opus',
|
|
711
|
+
runtimeTools: [],
|
|
712
|
+
runtimeTimeoutMs: 1000,
|
|
713
|
+
requireChannelContext: false,
|
|
714
|
+
autoIndexChannelContext: false,
|
|
715
|
+
autoJoinThreads: false,
|
|
716
|
+
useRuntimeSessions: true,
|
|
717
|
+
discordActionsEnabled: false,
|
|
718
|
+
discordActionsChannels: false,
|
|
719
|
+
discordActionsMessaging: false,
|
|
720
|
+
discordActionsGuild: false,
|
|
721
|
+
discordActionsModeration: false,
|
|
722
|
+
discordActionsPolls: false,
|
|
723
|
+
discordActionsTasks: false,
|
|
724
|
+
discordActionsBotProfile: false,
|
|
725
|
+
messageHistoryBudget: 0,
|
|
726
|
+
summaryEnabled: false,
|
|
727
|
+
summaryModel: 'haiku',
|
|
728
|
+
summaryMaxChars: 2000,
|
|
729
|
+
summaryEveryNTurns: 5,
|
|
730
|
+
summaryDataDir: '/tmp/summaries',
|
|
731
|
+
summaryToDurableEnabled: false,
|
|
732
|
+
shortTermMemoryEnabled: false,
|
|
733
|
+
shortTermDataDir: '/tmp/shortterm',
|
|
734
|
+
shortTermMaxEntries: 20,
|
|
735
|
+
shortTermMaxAgeMs: 21600000,
|
|
736
|
+
shortTermInjectMaxChars: 1000,
|
|
737
|
+
durableMemoryEnabled: false,
|
|
738
|
+
durableDataDir: '/tmp/durable',
|
|
739
|
+
durableInjectMaxChars: 2000,
|
|
740
|
+
durableMaxItems: 200,
|
|
741
|
+
memoryCommandsEnabled: false,
|
|
742
|
+
actionFollowupDepth: 0,
|
|
743
|
+
reactionHandlerEnabled: false,
|
|
744
|
+
reactionMaxAgeMs: 86400000,
|
|
745
|
+
streamStallWarningMs: 0,
|
|
746
|
+
botDisplayName: 'TestBot',
|
|
747
|
+
taskCtx: {
|
|
748
|
+
tasksCwd: '/tmp/beads',
|
|
749
|
+
forumId: BEAD_FORUM_ID,
|
|
750
|
+
tagMap: {},
|
|
751
|
+
runtime: {},
|
|
752
|
+
autoTag: false,
|
|
753
|
+
autoTagModel: 'haiku',
|
|
754
|
+
},
|
|
755
|
+
...overrides,
|
|
756
|
+
};
|
|
757
|
+
return { queue, runtime, params, getPrompt: () => seenPrompt };
|
|
758
|
+
}
|
|
759
|
+
describe('bead context injection into prompt', () => {
|
|
760
|
+
beforeEach(() => {
|
|
761
|
+
mockedCacheGet.mockReset().mockResolvedValue(null);
|
|
762
|
+
});
|
|
763
|
+
it('includes bead section when message is from a bead forum thread', async () => {
|
|
764
|
+
mockedCacheGet.mockResolvedValue({
|
|
765
|
+
id: 'ws-042',
|
|
766
|
+
title: 'Fix auth bug',
|
|
767
|
+
status: 'in_progress',
|
|
768
|
+
priority: 2,
|
|
769
|
+
owner: 'David',
|
|
770
|
+
});
|
|
771
|
+
const { queue, runtime, params, getPrompt } = makeBeadParams();
|
|
772
|
+
const handler = createMessageCreateHandler(params, queue);
|
|
773
|
+
await handler(makeMsg({
|
|
774
|
+
channelId: 'bead-thread-1',
|
|
775
|
+
channel: {
|
|
776
|
+
send: vi.fn(async () => { }),
|
|
777
|
+
isThread: () => true,
|
|
778
|
+
parentId: BEAD_FORUM_ID,
|
|
779
|
+
name: 'bead-thread',
|
|
780
|
+
id: 'bead-thread-1',
|
|
781
|
+
},
|
|
782
|
+
}));
|
|
783
|
+
expect(runtime.invoke).toHaveBeenCalled();
|
|
784
|
+
expect(getPrompt()).toContain('Task context for this thread');
|
|
785
|
+
expect(getPrompt()).toContain('ws-042');
|
|
786
|
+
expect(getPrompt()).toContain('Fix auth bug');
|
|
787
|
+
});
|
|
788
|
+
it('does not include bead section for DMs', async () => {
|
|
789
|
+
const { queue, runtime, params, getPrompt } = makeBeadParams();
|
|
790
|
+
const handler = createMessageCreateHandler(params, queue);
|
|
791
|
+
await handler(makeMsg({ guildId: null, channelId: 'dmchan' }));
|
|
792
|
+
expect(runtime.invoke).toHaveBeenCalled();
|
|
793
|
+
expect(getPrompt()).not.toContain('Task context for this thread');
|
|
794
|
+
});
|
|
795
|
+
it('does not include bead section for non-bead thread channels', async () => {
|
|
796
|
+
const { queue, runtime, params, getPrompt } = makeBeadParams();
|
|
797
|
+
const handler = createMessageCreateHandler(params, queue);
|
|
798
|
+
// Thread in a different parent channel (not the beads forum).
|
|
799
|
+
await handler(makeMsg({
|
|
800
|
+
channelId: 'other-thread-1',
|
|
801
|
+
channel: {
|
|
802
|
+
send: vi.fn(async () => { }),
|
|
803
|
+
isThread: () => true,
|
|
804
|
+
parentId: '99998888777766665555',
|
|
805
|
+
name: 'other-thread',
|
|
806
|
+
id: 'other-thread-1',
|
|
807
|
+
},
|
|
808
|
+
}));
|
|
809
|
+
expect(runtime.invoke).toHaveBeenCalled();
|
|
810
|
+
expect(getPrompt()).not.toContain('Task context for this thread');
|
|
811
|
+
// Cache should not even be called — forum ID mismatch short-circuits.
|
|
812
|
+
expect(mockedCacheGet).not.toHaveBeenCalled();
|
|
813
|
+
});
|
|
814
|
+
it('does not include bead section for non-thread guild channels', async () => {
|
|
815
|
+
const { queue, runtime, params, getPrompt } = makeBeadParams();
|
|
816
|
+
const handler = createMessageCreateHandler(params, queue);
|
|
817
|
+
await handler(makeMsg({ channelId: 'regular-channel' }));
|
|
818
|
+
expect(runtime.invoke).toHaveBeenCalled();
|
|
819
|
+
expect(getPrompt()).not.toContain('Task context for this thread');
|
|
820
|
+
expect(mockedCacheGet).not.toHaveBeenCalled();
|
|
821
|
+
});
|
|
822
|
+
});
|
|
823
|
+
describe('!memory remember threads Discord metadata into durable source', () => {
|
|
824
|
+
it('stores guildId, channelId, messageId, and channelName', async () => {
|
|
825
|
+
const queue = makeQueue();
|
|
826
|
+
const runtime = {
|
|
827
|
+
invoke: vi.fn(async function* () {
|
|
828
|
+
yield { type: 'text_final', text: 'should not run' };
|
|
829
|
+
}),
|
|
830
|
+
};
|
|
831
|
+
const durableDir = await fs.mkdtemp(path.join(os.tmpdir(), 'metadata-integration-'));
|
|
832
|
+
const summaryDir = await fs.mkdtemp(path.join(os.tmpdir(), 'metadata-summary-'));
|
|
833
|
+
const handler = createMessageCreateHandler({
|
|
834
|
+
allowUserIds: new Set(['123']),
|
|
835
|
+
runtime,
|
|
836
|
+
sessionManager: { getOrCreate: vi.fn(async () => 'sess') },
|
|
837
|
+
workspaceCwd: '/tmp',
|
|
838
|
+
projectCwd: '/tmp',
|
|
839
|
+
groupsDir: '/tmp',
|
|
840
|
+
useGroupDirCwd: false,
|
|
841
|
+
runtimeModel: 'opus',
|
|
842
|
+
runtimeTools: [],
|
|
843
|
+
runtimeTimeoutMs: 1000,
|
|
844
|
+
requireChannelContext: false,
|
|
845
|
+
autoIndexChannelContext: false,
|
|
846
|
+
autoJoinThreads: false,
|
|
847
|
+
useRuntimeSessions: true,
|
|
848
|
+
discordActionsEnabled: false,
|
|
849
|
+
discordActionsChannels: false,
|
|
850
|
+
discordActionsMessaging: false,
|
|
851
|
+
discordActionsGuild: false,
|
|
852
|
+
discordActionsModeration: false,
|
|
853
|
+
discordActionsPolls: false,
|
|
854
|
+
discordActionsTasks: false,
|
|
855
|
+
discordActionsBotProfile: false,
|
|
856
|
+
messageHistoryBudget: 0,
|
|
857
|
+
summaryEnabled: false,
|
|
858
|
+
summaryModel: 'haiku',
|
|
859
|
+
summaryMaxChars: 2000,
|
|
860
|
+
summaryEveryNTurns: 5,
|
|
861
|
+
summaryDataDir: summaryDir,
|
|
862
|
+
summaryToDurableEnabled: false,
|
|
863
|
+
shortTermMemoryEnabled: false,
|
|
864
|
+
shortTermDataDir: '/tmp/shortterm',
|
|
865
|
+
shortTermMaxEntries: 20,
|
|
866
|
+
shortTermMaxAgeMs: 21600000,
|
|
867
|
+
shortTermInjectMaxChars: 1000,
|
|
868
|
+
durableMemoryEnabled: true,
|
|
869
|
+
durableDataDir: durableDir,
|
|
870
|
+
durableInjectMaxChars: 2000,
|
|
871
|
+
durableMaxItems: 200,
|
|
872
|
+
memoryCommandsEnabled: true,
|
|
873
|
+
actionFollowupDepth: 0,
|
|
874
|
+
reactionHandlerEnabled: false,
|
|
875
|
+
reactionRemoveHandlerEnabled: false,
|
|
876
|
+
reactionMaxAgeMs: 86400000,
|
|
877
|
+
streamStallWarningMs: 0,
|
|
878
|
+
botDisplayName: 'TestBot',
|
|
879
|
+
}, queue);
|
|
880
|
+
await handler(makeMsg({
|
|
881
|
+
content: '!memory remember test fact',
|
|
882
|
+
guildId: 'g1',
|
|
883
|
+
channelId: 'ch1',
|
|
884
|
+
id: 'msg1',
|
|
885
|
+
channel: { send: vi.fn(async () => { }), isThread: () => false, name: 'dev' },
|
|
886
|
+
}));
|
|
887
|
+
const store = await loadDurableMemory(durableDir, '123');
|
|
888
|
+
expect(store).not.toBeNull();
|
|
889
|
+
expect(store.items).toHaveLength(1);
|
|
890
|
+
expect(store.items[0].source.guildId).toBe('g1');
|
|
891
|
+
expect(store.items[0].source.channelId).toBe('ch1');
|
|
892
|
+
expect(store.items[0].source.messageId).toBe('msg1');
|
|
893
|
+
expect(store.items[0].source.channelName).toBe('dev');
|
|
894
|
+
});
|
|
895
|
+
});
|
|
896
|
+
// ---------------------------------------------------------------------------
|
|
897
|
+
// inlineContextFiles required-file enforcement
|
|
898
|
+
// ---------------------------------------------------------------------------
|
|
899
|
+
describe('inlineContextFiles required option', () => {
|
|
900
|
+
it('throws when a required file is missing', async () => {
|
|
901
|
+
const missingPath = path.join(os.tmpdir(), 'nonexistent-file-' + Date.now() + '.md');
|
|
902
|
+
await expect(inlineContextFiles([missingPath], { required: new Set([missingPath]) })).rejects.toThrow(/Required context file unreadable/);
|
|
903
|
+
});
|
|
904
|
+
it('includes required file content normally when present', async () => {
|
|
905
|
+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'inline-req-'));
|
|
906
|
+
const filePath = path.join(dir, 'test.md');
|
|
907
|
+
await fs.writeFile(filePath, '# Test content', 'utf-8');
|
|
908
|
+
const result = await inlineContextFiles([filePath], { required: new Set([filePath]) });
|
|
909
|
+
expect(result).toContain('# Test content');
|
|
910
|
+
});
|
|
911
|
+
it('silently skips non-required missing files', async () => {
|
|
912
|
+
const missingPath = path.join(os.tmpdir(), 'nonexistent-file-' + Date.now() + '.md');
|
|
913
|
+
const result = await inlineContextFiles([missingPath]);
|
|
914
|
+
expect(result).toBe('');
|
|
915
|
+
});
|
|
916
|
+
});
|
|
917
|
+
// ---------------------------------------------------------------------------
|
|
918
|
+
// Bead resolution dispatch wiring for !plan and !forge create
|
|
919
|
+
// ---------------------------------------------------------------------------
|
|
920
|
+
describe('bead resolution dispatch wiring', () => {
|
|
921
|
+
beforeEach(() => {
|
|
922
|
+
mockedCacheGet.mockReset().mockResolvedValue(null);
|
|
923
|
+
});
|
|
924
|
+
function makePlanForgeParams(overrides) {
|
|
925
|
+
const tmpDir = os.tmpdir();
|
|
926
|
+
const workspaceCwd = path.join(tmpDir, `plan-forge-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
927
|
+
const queue = makeQueue();
|
|
928
|
+
const runtime = {
|
|
929
|
+
invoke: vi.fn(async function* (p) {
|
|
930
|
+
yield { type: 'text_final', text: 'ok' };
|
|
931
|
+
}),
|
|
932
|
+
};
|
|
933
|
+
const store = new TaskStore({ prefix: 'ws' });
|
|
934
|
+
const params = {
|
|
935
|
+
allowUserIds: new Set(['123']),
|
|
936
|
+
runtime,
|
|
937
|
+
sessionManager: { getOrCreate: vi.fn(async () => 'sess') },
|
|
938
|
+
workspaceCwd,
|
|
939
|
+
projectCwd: '/tmp',
|
|
940
|
+
groupsDir: '/tmp',
|
|
941
|
+
useGroupDirCwd: false,
|
|
942
|
+
runtimeModel: 'opus',
|
|
943
|
+
runtimeTools: [],
|
|
944
|
+
runtimeTimeoutMs: 1000,
|
|
945
|
+
requireChannelContext: false,
|
|
946
|
+
autoIndexChannelContext: false,
|
|
947
|
+
autoJoinThreads: false,
|
|
948
|
+
useRuntimeSessions: true,
|
|
949
|
+
discordActionsEnabled: false,
|
|
950
|
+
discordActionsChannels: false,
|
|
951
|
+
discordActionsMessaging: false,
|
|
952
|
+
discordActionsGuild: false,
|
|
953
|
+
discordActionsModeration: false,
|
|
954
|
+
discordActionsPolls: false,
|
|
955
|
+
discordActionsTasks: false,
|
|
956
|
+
discordActionsBotProfile: false,
|
|
957
|
+
messageHistoryBudget: 0,
|
|
958
|
+
summaryEnabled: false,
|
|
959
|
+
summaryModel: 'haiku',
|
|
960
|
+
summaryMaxChars: 2000,
|
|
961
|
+
summaryEveryNTurns: 5,
|
|
962
|
+
summaryDataDir: '/tmp/summaries',
|
|
963
|
+
summaryToDurableEnabled: false,
|
|
964
|
+
shortTermMemoryEnabled: false,
|
|
965
|
+
shortTermDataDir: '/tmp/shortterm',
|
|
966
|
+
shortTermMaxEntries: 20,
|
|
967
|
+
shortTermMaxAgeMs: 21600000,
|
|
968
|
+
shortTermInjectMaxChars: 1000,
|
|
969
|
+
durableMemoryEnabled: false,
|
|
970
|
+
durableDataDir: '/tmp/durable',
|
|
971
|
+
durableInjectMaxChars: 2000,
|
|
972
|
+
durableMaxItems: 200,
|
|
973
|
+
memoryCommandsEnabled: false,
|
|
974
|
+
actionFollowupDepth: 0,
|
|
975
|
+
reactionHandlerEnabled: false,
|
|
976
|
+
reactionRemoveHandlerEnabled: false,
|
|
977
|
+
reactionMaxAgeMs: 86400000,
|
|
978
|
+
streamStallWarningMs: 0,
|
|
979
|
+
botDisplayName: 'TestBot',
|
|
980
|
+
planCommandsEnabled: true,
|
|
981
|
+
taskCtx: {
|
|
982
|
+
tasksCwd: '/tmp/beads',
|
|
983
|
+
forumId: BEAD_FORUM_ID,
|
|
984
|
+
tagMap: {},
|
|
985
|
+
store,
|
|
986
|
+
runtime: {},
|
|
987
|
+
autoTag: false,
|
|
988
|
+
autoTagModel: 'haiku',
|
|
989
|
+
},
|
|
990
|
+
...overrides,
|
|
991
|
+
};
|
|
992
|
+
return { queue, runtime, params, workspaceCwd, store };
|
|
993
|
+
}
|
|
994
|
+
it('!plan create in bead forum thread resolves existingBeadId — skips store.create', async () => {
|
|
995
|
+
mockedCacheGet.mockResolvedValue({
|
|
996
|
+
id: 'existing-bead-42',
|
|
997
|
+
title: 'Existing bead',
|
|
998
|
+
status: 'open',
|
|
999
|
+
priority: 2,
|
|
1000
|
+
owner: 'David',
|
|
1001
|
+
});
|
|
1002
|
+
const { queue, params, workspaceCwd, store } = makePlanForgeParams();
|
|
1003
|
+
const createSpy = vi.spyOn(store, 'create');
|
|
1004
|
+
const handler = createMessageCreateHandler(params, queue);
|
|
1005
|
+
await handler(makeMsg({
|
|
1006
|
+
content: '!plan fix the thread bug',
|
|
1007
|
+
channelId: 'bead-thread-plan',
|
|
1008
|
+
channel: {
|
|
1009
|
+
send: vi.fn(async () => { }),
|
|
1010
|
+
isThread: () => true,
|
|
1011
|
+
parentId: BEAD_FORUM_ID,
|
|
1012
|
+
name: 'bead-thread',
|
|
1013
|
+
id: 'bead-thread-plan',
|
|
1014
|
+
},
|
|
1015
|
+
}));
|
|
1016
|
+
expect(mockedCacheGet).toHaveBeenCalledWith('bead-thread-plan', expect.any(Object));
|
|
1017
|
+
expect(createSpy).not.toHaveBeenCalled();
|
|
1018
|
+
// Verify the plan file uses the existing task ID.
|
|
1019
|
+
const plansDir = path.join(workspaceCwd, 'plans');
|
|
1020
|
+
const files = await fs.readdir(plansDir);
|
|
1021
|
+
const planFile = files.find((f) => f.endsWith('.md'));
|
|
1022
|
+
expect(planFile).toBeTruthy();
|
|
1023
|
+
const content = await fs.readFile(path.join(plansDir, planFile), 'utf-8');
|
|
1024
|
+
expect(content).toContain('**Task:** existing-bead-42');
|
|
1025
|
+
});
|
|
1026
|
+
it('!plan create in non-bead-forum thread does NOT resolve — calls store.create', async () => {
|
|
1027
|
+
const { queue, params, store } = makePlanForgeParams();
|
|
1028
|
+
const createSpy = vi.spyOn(store, 'create');
|
|
1029
|
+
const handler = createMessageCreateHandler(params, queue);
|
|
1030
|
+
await handler(makeMsg({
|
|
1031
|
+
content: '!plan fix something',
|
|
1032
|
+
channelId: 'other-thread',
|
|
1033
|
+
channel: {
|
|
1034
|
+
send: vi.fn(async () => { }),
|
|
1035
|
+
isThread: () => true,
|
|
1036
|
+
parentId: '99998888777766665555', // Different from BEAD_FORUM_ID
|
|
1037
|
+
name: 'other-thread',
|
|
1038
|
+
id: 'other-thread',
|
|
1039
|
+
},
|
|
1040
|
+
}));
|
|
1041
|
+
expect(mockedCacheGet).not.toHaveBeenCalled();
|
|
1042
|
+
expect(createSpy).toHaveBeenCalled();
|
|
1043
|
+
});
|
|
1044
|
+
it('!plan create when cache returns null falls through to store.create', async () => {
|
|
1045
|
+
mockedCacheGet.mockResolvedValue(null);
|
|
1046
|
+
const { queue, params, store } = makePlanForgeParams();
|
|
1047
|
+
const createSpy = vi.spyOn(store, 'create');
|
|
1048
|
+
const handler = createMessageCreateHandler(params, queue);
|
|
1049
|
+
await handler(makeMsg({
|
|
1050
|
+
content: '!plan fix the thread bug',
|
|
1051
|
+
channelId: 'bead-thread-null',
|
|
1052
|
+
channel: {
|
|
1053
|
+
send: vi.fn(async () => { }),
|
|
1054
|
+
isThread: () => true,
|
|
1055
|
+
parentId: BEAD_FORUM_ID,
|
|
1056
|
+
name: 'bead-thread',
|
|
1057
|
+
id: 'bead-thread-null',
|
|
1058
|
+
},
|
|
1059
|
+
}));
|
|
1060
|
+
expect(mockedCacheGet).toHaveBeenCalledWith('bead-thread-null', expect.any(Object));
|
|
1061
|
+
expect(createSpy).toHaveBeenCalled();
|
|
1062
|
+
});
|
|
1063
|
+
it('!plan create without taskCtx skips bead lookup entirely', async () => {
|
|
1064
|
+
const { queue, params, workspaceCwd } = makePlanForgeParams({ taskCtx: undefined });
|
|
1065
|
+
const handler = createMessageCreateHandler(params, queue);
|
|
1066
|
+
await handler(makeMsg({
|
|
1067
|
+
content: '!plan fix the bug',
|
|
1068
|
+
channelId: 'bead-thread-no-ctx',
|
|
1069
|
+
channel: {
|
|
1070
|
+
send: vi.fn(async () => { }),
|
|
1071
|
+
isThread: () => true,
|
|
1072
|
+
parentId: BEAD_FORUM_ID,
|
|
1073
|
+
name: 'bead-thread',
|
|
1074
|
+
id: 'bead-thread-no-ctx',
|
|
1075
|
+
},
|
|
1076
|
+
}));
|
|
1077
|
+
expect(mockedCacheGet).not.toHaveBeenCalled();
|
|
1078
|
+
// Plan file should be created (bead is created via ephemeral TaskStore)
|
|
1079
|
+
const plansDir = path.join(workspaceCwd, 'plans');
|
|
1080
|
+
const files = await fs.readdir(plansDir);
|
|
1081
|
+
expect(files.find((f) => f.endsWith('.md'))).toBeTruthy();
|
|
1082
|
+
});
|
|
1083
|
+
it('!forge create in bead forum thread resolves existingBeadId', async () => {
|
|
1084
|
+
mockedCacheGet.mockResolvedValue({
|
|
1085
|
+
id: 'existing-forge-bead',
|
|
1086
|
+
title: 'Forge bead',
|
|
1087
|
+
status: 'open',
|
|
1088
|
+
priority: 2,
|
|
1089
|
+
owner: 'David',
|
|
1090
|
+
});
|
|
1091
|
+
const { queue, params } = makePlanForgeParams({ forgeCommandsEnabled: true });
|
|
1092
|
+
const handler = createMessageCreateHandler(params, queue);
|
|
1093
|
+
await handler(makeMsg({
|
|
1094
|
+
content: '!forge fix the thread bug',
|
|
1095
|
+
channelId: 'bead-thread-forge',
|
|
1096
|
+
channel: {
|
|
1097
|
+
send: vi.fn(async () => { }),
|
|
1098
|
+
isThread: () => true,
|
|
1099
|
+
parentId: BEAD_FORUM_ID,
|
|
1100
|
+
name: 'bead-thread',
|
|
1101
|
+
id: 'bead-thread-forge',
|
|
1102
|
+
},
|
|
1103
|
+
}));
|
|
1104
|
+
// Verify the bead lookup happened for the forge path
|
|
1105
|
+
expect(mockedCacheGet).toHaveBeenCalledWith('bead-thread-forge', expect.any(Object));
|
|
1106
|
+
});
|
|
1107
|
+
it('!forge create in non-bead-forum thread does NOT resolve existingBeadId', async () => {
|
|
1108
|
+
const { queue, params } = makePlanForgeParams({ forgeCommandsEnabled: true });
|
|
1109
|
+
const handler = createMessageCreateHandler(params, queue);
|
|
1110
|
+
await handler(makeMsg({
|
|
1111
|
+
content: '!forge fix something',
|
|
1112
|
+
channelId: 'other-forge-thread',
|
|
1113
|
+
channel: {
|
|
1114
|
+
send: vi.fn(async () => { }),
|
|
1115
|
+
isThread: () => true,
|
|
1116
|
+
parentId: '99998888777766665555',
|
|
1117
|
+
name: 'other-thread',
|
|
1118
|
+
id: 'other-forge-thread',
|
|
1119
|
+
},
|
|
1120
|
+
}));
|
|
1121
|
+
// beadThreadCache.get should NOT be called — forum ID mismatch short-circuits
|
|
1122
|
+
expect(mockedCacheGet).not.toHaveBeenCalled();
|
|
1123
|
+
});
|
|
1124
|
+
// -------------------------------------------------------------------------
|
|
1125
|
+
// Plan/forge channel history fallback
|
|
1126
|
+
// -------------------------------------------------------------------------
|
|
1127
|
+
describe('plan/forge channel history fallback', () => {
|
|
1128
|
+
function fakeHistoryMsg(id, content, username, bot = false) {
|
|
1129
|
+
return {
|
|
1130
|
+
id,
|
|
1131
|
+
content,
|
|
1132
|
+
author: { username, displayName: username, bot },
|
|
1133
|
+
};
|
|
1134
|
+
}
|
|
1135
|
+
it('!plan create in regular channel with history — captures context', async () => {
|
|
1136
|
+
const { queue, params, workspaceCwd } = makePlanForgeParams({ messageHistoryBudget: 3000 });
|
|
1137
|
+
const handler = createMessageCreateHandler(params, queue);
|
|
1138
|
+
const channelFetch = vi.fn(async () => new Map([
|
|
1139
|
+
['1', fakeHistoryMsg('1', 'the header is misaligned', 'Alice')],
|
|
1140
|
+
['2', fakeHistoryMsg('2', 'yeah I see it too', 'Bob')],
|
|
1141
|
+
]));
|
|
1142
|
+
await handler(makeMsg({
|
|
1143
|
+
content: '!plan fix the layout bug',
|
|
1144
|
+
channelId: 'regular-chan',
|
|
1145
|
+
channel: {
|
|
1146
|
+
send: vi.fn(async () => { }),
|
|
1147
|
+
isThread: () => false,
|
|
1148
|
+
name: 'general',
|
|
1149
|
+
id: 'regular-chan',
|
|
1150
|
+
messages: { fetch: channelFetch },
|
|
1151
|
+
},
|
|
1152
|
+
}));
|
|
1153
|
+
const plansDir = path.join(workspaceCwd, 'plans');
|
|
1154
|
+
const files = await fs.readdir(plansDir);
|
|
1155
|
+
const planFile = files.find((f) => f.endsWith('.md'));
|
|
1156
|
+
expect(planFile).toBeTruthy();
|
|
1157
|
+
const content = await fs.readFile(path.join(plansDir, planFile), 'utf-8');
|
|
1158
|
+
expect(content).toContain('Context (recent channel messages):');
|
|
1159
|
+
expect(content).toContain('[Alice]: the header is misaligned');
|
|
1160
|
+
});
|
|
1161
|
+
it('!plan create in regular channel with empty history — no context section', async () => {
|
|
1162
|
+
const { queue, params, workspaceCwd } = makePlanForgeParams({ messageHistoryBudget: 3000 });
|
|
1163
|
+
const handler = createMessageCreateHandler(params, queue);
|
|
1164
|
+
await handler(makeMsg({
|
|
1165
|
+
content: '!plan fix something',
|
|
1166
|
+
channelId: 'empty-chan',
|
|
1167
|
+
channel: {
|
|
1168
|
+
send: vi.fn(async () => { }),
|
|
1169
|
+
isThread: () => false,
|
|
1170
|
+
name: 'general',
|
|
1171
|
+
id: 'empty-chan',
|
|
1172
|
+
messages: { fetch: vi.fn(async () => new Map()) },
|
|
1173
|
+
},
|
|
1174
|
+
}));
|
|
1175
|
+
const plansDir = path.join(workspaceCwd, 'plans');
|
|
1176
|
+
const files = await fs.readdir(plansDir);
|
|
1177
|
+
const planFile = files.find((f) => f.endsWith('.md'));
|
|
1178
|
+
expect(planFile).toBeTruthy();
|
|
1179
|
+
const content = await fs.readFile(path.join(plansDir, planFile), 'utf-8');
|
|
1180
|
+
expect(content).not.toContain('## Context');
|
|
1181
|
+
});
|
|
1182
|
+
it('!plan create with reply reference — fallback NOT called', async () => {
|
|
1183
|
+
const { queue, params, workspaceCwd } = makePlanForgeParams({ messageHistoryBudget: 3000 });
|
|
1184
|
+
const handler = createMessageCreateHandler(params, queue);
|
|
1185
|
+
const channelFetch = vi.fn(async (opts) => {
|
|
1186
|
+
// Single-ID fetch for resolveReplyReference
|
|
1187
|
+
if (typeof opts === 'string') {
|
|
1188
|
+
return fakeHistoryMsg('ref-1', 'the original issue description', 'Alice');
|
|
1189
|
+
}
|
|
1190
|
+
// History-style fetch should not be called
|
|
1191
|
+
throw new Error('history fetch should not be called');
|
|
1192
|
+
});
|
|
1193
|
+
await handler(makeMsg({
|
|
1194
|
+
content: '!plan fix the layout bug',
|
|
1195
|
+
channelId: 'reply-chan',
|
|
1196
|
+
channel: {
|
|
1197
|
+
send: vi.fn(async () => { }),
|
|
1198
|
+
isThread: () => false,
|
|
1199
|
+
name: 'general',
|
|
1200
|
+
id: 'reply-chan',
|
|
1201
|
+
messages: { fetch: channelFetch },
|
|
1202
|
+
},
|
|
1203
|
+
reference: { messageId: 'ref-1' },
|
|
1204
|
+
}));
|
|
1205
|
+
const plansDir = path.join(workspaceCwd, 'plans');
|
|
1206
|
+
const files = await fs.readdir(plansDir);
|
|
1207
|
+
const planFile = files.find((f) => f.endsWith('.md'));
|
|
1208
|
+
expect(planFile).toBeTruthy();
|
|
1209
|
+
const content = await fs.readFile(path.join(plansDir, planFile), 'utf-8');
|
|
1210
|
+
expect(content).toContain('Context (replied-to message):');
|
|
1211
|
+
expect(content).not.toContain('Context (recent channel messages):');
|
|
1212
|
+
});
|
|
1213
|
+
it('!plan create with budget 0 — fallback NOT called', async () => {
|
|
1214
|
+
const { queue, params, workspaceCwd } = makePlanForgeParams({ messageHistoryBudget: 0 });
|
|
1215
|
+
const handler = createMessageCreateHandler(params, queue);
|
|
1216
|
+
const channelFetch = vi.fn(async () => new Map([
|
|
1217
|
+
['1', fakeHistoryMsg('1', 'some message', 'Alice')],
|
|
1218
|
+
]));
|
|
1219
|
+
await handler(makeMsg({
|
|
1220
|
+
content: '!plan fix the bug',
|
|
1221
|
+
channelId: 'budget-zero-chan',
|
|
1222
|
+
channel: {
|
|
1223
|
+
send: vi.fn(async () => { }),
|
|
1224
|
+
isThread: () => false,
|
|
1225
|
+
name: 'general',
|
|
1226
|
+
id: 'budget-zero-chan',
|
|
1227
|
+
messages: { fetch: channelFetch },
|
|
1228
|
+
},
|
|
1229
|
+
}));
|
|
1230
|
+
const plansDir = path.join(workspaceCwd, 'plans');
|
|
1231
|
+
const files = await fs.readdir(plansDir);
|
|
1232
|
+
const planFile = files.find((f) => f.endsWith('.md'));
|
|
1233
|
+
expect(planFile).toBeTruthy();
|
|
1234
|
+
const content = await fs.readFile(path.join(plansDir, planFile), 'utf-8');
|
|
1235
|
+
expect(content).not.toContain('## Context');
|
|
1236
|
+
});
|
|
1237
|
+
it('!plan create with history fetch error — proceeds without context', async () => {
|
|
1238
|
+
const { queue, params, workspaceCwd } = makePlanForgeParams({ messageHistoryBudget: 3000 });
|
|
1239
|
+
const handler = createMessageCreateHandler(params, queue);
|
|
1240
|
+
await handler(makeMsg({
|
|
1241
|
+
content: '!plan fix the bug',
|
|
1242
|
+
channelId: 'error-chan',
|
|
1243
|
+
channel: {
|
|
1244
|
+
send: vi.fn(async () => { }),
|
|
1245
|
+
isThread: () => false,
|
|
1246
|
+
name: 'general',
|
|
1247
|
+
id: 'error-chan',
|
|
1248
|
+
messages: { fetch: vi.fn(async () => { throw new Error('forbidden'); }) },
|
|
1249
|
+
},
|
|
1250
|
+
}));
|
|
1251
|
+
const plansDir = path.join(workspaceCwd, 'plans');
|
|
1252
|
+
const files = await fs.readdir(plansDir);
|
|
1253
|
+
const planFile = files.find((f) => f.endsWith('.md'));
|
|
1254
|
+
expect(planFile).toBeTruthy();
|
|
1255
|
+
const content = await fs.readFile(path.join(plansDir, planFile), 'utf-8');
|
|
1256
|
+
expect(content).not.toContain('## Context');
|
|
1257
|
+
});
|
|
1258
|
+
it('!plan create inside a thread with reply and pinned context includes both sections', async () => {
|
|
1259
|
+
const { queue, params, workspaceCwd } = makePlanForgeParams({ messageHistoryBudget: 3000 });
|
|
1260
|
+
const handler = createMessageCreateHandler(params, queue);
|
|
1261
|
+
const replyMessage = {
|
|
1262
|
+
id: 'ref-thread-1',
|
|
1263
|
+
content: 'The issue is that the dashboard hides metrics',
|
|
1264
|
+
author: { username: 'Alice', displayName: 'Alice', bot: false },
|
|
1265
|
+
};
|
|
1266
|
+
const threadStarter = {
|
|
1267
|
+
id: 'starter-thread-1',
|
|
1268
|
+
content: 'Opening the debugging thread',
|
|
1269
|
+
author: { username: 'Bob', displayName: 'Bob', bot: false },
|
|
1270
|
+
};
|
|
1271
|
+
const threadReply = {
|
|
1272
|
+
id: 'thread-msg-1',
|
|
1273
|
+
content: 'Following up on the debugging thread',
|
|
1274
|
+
author: { username: 'Charlie', displayName: 'Charlie', bot: false },
|
|
1275
|
+
};
|
|
1276
|
+
const pinnedMessage = {
|
|
1277
|
+
id: 'pinned-1',
|
|
1278
|
+
content: 'Pinned note: keep the UX consistent',
|
|
1279
|
+
author: { username: 'Discoclaw', displayName: 'Discoclaw', bot: true },
|
|
1280
|
+
};
|
|
1281
|
+
const messagesFetch = vi.fn(async (opts) => {
|
|
1282
|
+
if (typeof opts === 'string')
|
|
1283
|
+
return replyMessage;
|
|
1284
|
+
const map = new Map();
|
|
1285
|
+
map.set(threadReply.id, threadReply);
|
|
1286
|
+
return map;
|
|
1287
|
+
});
|
|
1288
|
+
const handlerMsg = makeMsg({
|
|
1289
|
+
content: '!plan fix the thread bug',
|
|
1290
|
+
channelId: 'thread-plan-context',
|
|
1291
|
+
channel: {
|
|
1292
|
+
send: vi.fn(async () => { }),
|
|
1293
|
+
isThread: () => true,
|
|
1294
|
+
parentId: 'parent-thread',
|
|
1295
|
+
name: 'thread-name',
|
|
1296
|
+
id: 'thread-plan-context',
|
|
1297
|
+
fetchStarterMessage: vi.fn(async () => threadStarter),
|
|
1298
|
+
messages: {
|
|
1299
|
+
fetch: messagesFetch,
|
|
1300
|
+
fetchPinned: vi.fn(async () => {
|
|
1301
|
+
const map = new Map();
|
|
1302
|
+
map.set(pinnedMessage.id, pinnedMessage);
|
|
1303
|
+
return map;
|
|
1304
|
+
}),
|
|
1305
|
+
},
|
|
1306
|
+
},
|
|
1307
|
+
reference: { messageId: replyMessage.id },
|
|
1308
|
+
});
|
|
1309
|
+
await handler(handlerMsg);
|
|
1310
|
+
const plansDir = path.join(workspaceCwd, 'plans');
|
|
1311
|
+
const files = await fs.readdir(plansDir);
|
|
1312
|
+
const planFile = files.find((f) => f.endsWith('.md'));
|
|
1313
|
+
expect(planFile).toBeTruthy();
|
|
1314
|
+
const content = await fs.readFile(path.join(plansDir, planFile), 'utf-8');
|
|
1315
|
+
expect(content).toContain('Context (replied-to message):');
|
|
1316
|
+
expect(content).toContain('[Alice]: The issue is that the dashboard hides metrics');
|
|
1317
|
+
expect(content).toContain('Thread: "thread-name"');
|
|
1318
|
+
expect(content).toContain('Pinned message:');
|
|
1319
|
+
expect(content).toContain('[TestBot]: Pinned note: keep the UX consistent (pinned id:pinned-1)');
|
|
1320
|
+
});
|
|
1321
|
+
});
|
|
1322
|
+
// -------------------------------------------------------------------------
|
|
1323
|
+
// Title-match dedup for !plan and !forge in non-bead channels
|
|
1324
|
+
// -------------------------------------------------------------------------
|
|
1325
|
+
describe('title-match dedup in non-bead channels', () => {
|
|
1326
|
+
it('!plan create reuses existing open bead with matching title — skips store.create', async () => {
|
|
1327
|
+
const { queue, params, workspaceCwd, store } = makePlanForgeParams();
|
|
1328
|
+
const existingBead = store.create({ title: 'fix the layout bug', labels: ['plan'] });
|
|
1329
|
+
const createSpy = vi.spyOn(store, 'create');
|
|
1330
|
+
const handler = createMessageCreateHandler(params, queue);
|
|
1331
|
+
await handler(makeMsg({
|
|
1332
|
+
content: '!plan fix the layout bug',
|
|
1333
|
+
channelId: 'regular-chan',
|
|
1334
|
+
channel: {
|
|
1335
|
+
send: vi.fn(async () => { }),
|
|
1336
|
+
isThread: () => false,
|
|
1337
|
+
name: 'general',
|
|
1338
|
+
id: 'regular-chan',
|
|
1339
|
+
},
|
|
1340
|
+
}));
|
|
1341
|
+
expect(createSpy).not.toHaveBeenCalled();
|
|
1342
|
+
// Verify the plan file uses the deduped task ID.
|
|
1343
|
+
const plansDir = path.join(workspaceCwd, 'plans');
|
|
1344
|
+
const files = await fs.readdir(plansDir);
|
|
1345
|
+
const planFile = files.find((f) => f.endsWith('.md'));
|
|
1346
|
+
expect(planFile).toBeTruthy();
|
|
1347
|
+
const content = await fs.readFile(path.join(plansDir, planFile), 'utf-8');
|
|
1348
|
+
expect(content).toContain(`**Task:** ${existingBead.id}`);
|
|
1349
|
+
});
|
|
1350
|
+
it('!plan create dedup is case-insensitive and trims whitespace', async () => {
|
|
1351
|
+
const { queue, params, workspaceCwd, store } = makePlanForgeParams();
|
|
1352
|
+
const existingBead = store.create({ title: ' Fix The Bug ', labels: ['plan'] });
|
|
1353
|
+
const createSpy = vi.spyOn(store, 'create');
|
|
1354
|
+
const handler = createMessageCreateHandler(params, queue);
|
|
1355
|
+
await handler(makeMsg({
|
|
1356
|
+
content: '!plan fix the bug',
|
|
1357
|
+
channelId: 'regular-chan',
|
|
1358
|
+
channel: {
|
|
1359
|
+
send: vi.fn(async () => { }),
|
|
1360
|
+
isThread: () => false,
|
|
1361
|
+
name: 'general',
|
|
1362
|
+
id: 'regular-chan',
|
|
1363
|
+
},
|
|
1364
|
+
}));
|
|
1365
|
+
expect(createSpy).not.toHaveBeenCalled();
|
|
1366
|
+
const plansDir = path.join(workspaceCwd, 'plans');
|
|
1367
|
+
const files = await fs.readdir(plansDir);
|
|
1368
|
+
const planFile = files.find((f) => f.endsWith('.md'));
|
|
1369
|
+
const content = await fs.readFile(path.join(plansDir, planFile), 'utf-8');
|
|
1370
|
+
expect(content).toContain(`**Task:** ${existingBead.id}`);
|
|
1371
|
+
});
|
|
1372
|
+
it('!plan create does not reuse closed beads with matching title', async () => {
|
|
1373
|
+
const { queue, params, store } = makePlanForgeParams();
|
|
1374
|
+
const closedBead = store.create({ title: 'fix the bug', labels: ['plan'] });
|
|
1375
|
+
store.close(closedBead.id, 'done');
|
|
1376
|
+
const createSpy = vi.spyOn(store, 'create');
|
|
1377
|
+
const handler = createMessageCreateHandler(params, queue);
|
|
1378
|
+
await handler(makeMsg({
|
|
1379
|
+
content: '!plan fix the bug',
|
|
1380
|
+
channelId: 'regular-chan',
|
|
1381
|
+
channel: {
|
|
1382
|
+
send: vi.fn(async () => { }),
|
|
1383
|
+
isThread: () => false,
|
|
1384
|
+
name: 'general',
|
|
1385
|
+
id: 'regular-chan',
|
|
1386
|
+
},
|
|
1387
|
+
}));
|
|
1388
|
+
expect(createSpy).toHaveBeenCalled();
|
|
1389
|
+
});
|
|
1390
|
+
it('!plan create calls store.create when no title match exists', async () => {
|
|
1391
|
+
const { queue, params, store } = makePlanForgeParams();
|
|
1392
|
+
store.create({ title: 'something unrelated', labels: ['plan'] });
|
|
1393
|
+
const createSpy = vi.spyOn(store, 'create');
|
|
1394
|
+
const handler = createMessageCreateHandler(params, queue);
|
|
1395
|
+
await handler(makeMsg({
|
|
1396
|
+
content: '!plan fix the bug',
|
|
1397
|
+
channelId: 'regular-chan',
|
|
1398
|
+
channel: {
|
|
1399
|
+
send: vi.fn(async () => { }),
|
|
1400
|
+
isThread: () => false,
|
|
1401
|
+
name: 'general',
|
|
1402
|
+
id: 'regular-chan',
|
|
1403
|
+
},
|
|
1404
|
+
}));
|
|
1405
|
+
expect(createSpy).toHaveBeenCalled();
|
|
1406
|
+
});
|
|
1407
|
+
it('!plan create dedup reuses in_progress bead with matching title', async () => {
|
|
1408
|
+
const { queue, params, workspaceCwd, store } = makePlanForgeParams();
|
|
1409
|
+
const inProgressBead = store.create({ title: 'fix the bug', labels: ['plan'] });
|
|
1410
|
+
store.update(inProgressBead.id, { status: 'in_progress' });
|
|
1411
|
+
const createSpy = vi.spyOn(store, 'create');
|
|
1412
|
+
const handler = createMessageCreateHandler(params, queue);
|
|
1413
|
+
await handler(makeMsg({
|
|
1414
|
+
content: '!plan fix the bug',
|
|
1415
|
+
channelId: 'regular-chan',
|
|
1416
|
+
channel: {
|
|
1417
|
+
send: vi.fn(async () => { }),
|
|
1418
|
+
isThread: () => false,
|
|
1419
|
+
name: 'general',
|
|
1420
|
+
id: 'regular-chan',
|
|
1421
|
+
},
|
|
1422
|
+
}));
|
|
1423
|
+
expect(createSpy).not.toHaveBeenCalled();
|
|
1424
|
+
const plansDir = path.join(workspaceCwd, 'plans');
|
|
1425
|
+
const files = await fs.readdir(plansDir);
|
|
1426
|
+
const planFile = files.find((f) => f.endsWith('.md'));
|
|
1427
|
+
const content = await fs.readFile(path.join(plansDir, planFile), 'utf-8');
|
|
1428
|
+
expect(content).toContain(`**Task:** ${inProgressBead.id}`);
|
|
1429
|
+
});
|
|
1430
|
+
});
|
|
1431
|
+
});
|