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,341 @@
|
|
|
1
|
+
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { parseStatusCommand, renderStatusReport, collectStatusSnapshot, } from './status-command.js';
|
|
3
|
+
import * as credentialCheck from '../health/credential-check.js';
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// Helpers
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
function makeSnapshot(overrides = {}) {
|
|
8
|
+
return {
|
|
9
|
+
uptimeMs: 3_661_000, // 1h 1m 1s
|
|
10
|
+
lastMessageAt: Date.now() - 90_000, // 1m 30s ago
|
|
11
|
+
crons: [],
|
|
12
|
+
openTaskCount: 0,
|
|
13
|
+
durableItemCount: 0,
|
|
14
|
+
rollingSummaryCharCount: 0,
|
|
15
|
+
apiChecks: [],
|
|
16
|
+
paFiles: [],
|
|
17
|
+
...overrides,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// parseStatusCommand
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
describe('parseStatusCommand', () => {
|
|
24
|
+
it('returns true for !status', () => {
|
|
25
|
+
expect(parseStatusCommand('!status')).toBe(true);
|
|
26
|
+
});
|
|
27
|
+
it('is case-insensitive', () => {
|
|
28
|
+
expect(parseStatusCommand('!STATUS')).toBe(true);
|
|
29
|
+
expect(parseStatusCommand('!Status')).toBe(true);
|
|
30
|
+
});
|
|
31
|
+
it('trims surrounding whitespace', () => {
|
|
32
|
+
expect(parseStatusCommand(' !status ')).toBe(true);
|
|
33
|
+
});
|
|
34
|
+
it('returns null for non-status commands', () => {
|
|
35
|
+
expect(parseStatusCommand('!health')).toBeNull();
|
|
36
|
+
expect(parseStatusCommand('!memory')).toBeNull();
|
|
37
|
+
expect(parseStatusCommand('hello')).toBeNull();
|
|
38
|
+
expect(parseStatusCommand('')).toBeNull();
|
|
39
|
+
});
|
|
40
|
+
it('returns null for !status with subcommands', () => {
|
|
41
|
+
expect(parseStatusCommand('!status verbose')).toBeNull();
|
|
42
|
+
expect(parseStatusCommand('!status foo')).toBeNull();
|
|
43
|
+
});
|
|
44
|
+
it('handles non-string input gracefully', () => {
|
|
45
|
+
expect(parseStatusCommand(undefined)).toBeNull();
|
|
46
|
+
expect(parseStatusCommand(null)).toBeNull();
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
// renderStatusReport
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
describe('renderStatusReport', () => {
|
|
53
|
+
it('renders uptime correctly', () => {
|
|
54
|
+
const out = renderStatusReport(makeSnapshot({ uptimeMs: 3_661_000 }));
|
|
55
|
+
expect(out).toContain('Uptime: 1h 1m 1s');
|
|
56
|
+
});
|
|
57
|
+
it('renders last message when present', () => {
|
|
58
|
+
const snap = makeSnapshot({ lastMessageAt: Date.now() - 90_000 });
|
|
59
|
+
const out = renderStatusReport(snap);
|
|
60
|
+
expect(out).toMatch(/Last message: \d+m ago/);
|
|
61
|
+
});
|
|
62
|
+
it('renders "none since startup" when lastMessageAt is null', () => {
|
|
63
|
+
const out = renderStatusReport(makeSnapshot({ lastMessageAt: null }));
|
|
64
|
+
expect(out).toContain('Last message: none since startup');
|
|
65
|
+
});
|
|
66
|
+
it('renders crons=none when empty', () => {
|
|
67
|
+
const out = renderStatusReport(makeSnapshot({ crons: [] }));
|
|
68
|
+
expect(out).toContain('Crons: none');
|
|
69
|
+
});
|
|
70
|
+
it('renders cron list with next run times', () => {
|
|
71
|
+
// Use 10m + 30s buffer so the "in Xm" label is stable regardless of test latency.
|
|
72
|
+
const futureDate = new Date(Date.now() + 10 * 60_000 + 30_000);
|
|
73
|
+
const snap = makeSnapshot({
|
|
74
|
+
crons: [
|
|
75
|
+
{ name: 'morning-report', schedule: '0 7 * * 1-5', nextRun: futureDate },
|
|
76
|
+
{ name: 'weekly-digest', schedule: '0 9 * * 1', nextRun: null },
|
|
77
|
+
{ name: 'manual-job', schedule: undefined, nextRun: null },
|
|
78
|
+
],
|
|
79
|
+
});
|
|
80
|
+
const out = renderStatusReport(snap);
|
|
81
|
+
expect(out).toContain('Crons (3):');
|
|
82
|
+
expect(out).toContain('morning-report: next=in 10m');
|
|
83
|
+
expect(out).toContain('weekly-digest: next=stopped');
|
|
84
|
+
expect(out).toContain('manual-job: next=manual/webhook');
|
|
85
|
+
});
|
|
86
|
+
it('renders imminent next run when date is in the past', () => {
|
|
87
|
+
const pastDate = new Date(Date.now() - 1000);
|
|
88
|
+
const snap = makeSnapshot({
|
|
89
|
+
crons: [{ name: 'overdue', schedule: '* * * * *', nextRun: pastDate }],
|
|
90
|
+
});
|
|
91
|
+
const out = renderStatusReport(snap);
|
|
92
|
+
expect(out).toContain('overdue: next=imminent');
|
|
93
|
+
});
|
|
94
|
+
it('renders open task count', () => {
|
|
95
|
+
const out = renderStatusReport(makeSnapshot({ openTaskCount: 7 }));
|
|
96
|
+
expect(out).toContain('Open tasks: 7');
|
|
97
|
+
});
|
|
98
|
+
it('renders memory stats', () => {
|
|
99
|
+
const out = renderStatusReport(makeSnapshot({ durableItemCount: 42, rollingSummaryCharCount: 1234 }));
|
|
100
|
+
expect(out).toContain('Memory: durable=42 items, summaries=1234 chars');
|
|
101
|
+
});
|
|
102
|
+
it('renders API check results', () => {
|
|
103
|
+
const snap = makeSnapshot({
|
|
104
|
+
apiChecks: [
|
|
105
|
+
{ name: 'discord-token', status: 'ok' },
|
|
106
|
+
{ name: 'openai-key', status: 'skip' },
|
|
107
|
+
],
|
|
108
|
+
});
|
|
109
|
+
const out = renderStatusReport(snap);
|
|
110
|
+
expect(out).toContain('API: discord-token: ok, openai-key: skip');
|
|
111
|
+
});
|
|
112
|
+
it('renders API FAIL with message', () => {
|
|
113
|
+
const snap = makeSnapshot({
|
|
114
|
+
apiChecks: [
|
|
115
|
+
{ name: 'discord-token', status: 'fail', message: 'invalid or revoked token (401)' },
|
|
116
|
+
],
|
|
117
|
+
});
|
|
118
|
+
const out = renderStatusReport(snap);
|
|
119
|
+
expect(out).toContain('discord-token: FAIL (invalid or revoked token (401))');
|
|
120
|
+
});
|
|
121
|
+
it('renders API: no checks when array is empty', () => {
|
|
122
|
+
const out = renderStatusReport(makeSnapshot({ apiChecks: [] }));
|
|
123
|
+
expect(out).toContain('API: no checks');
|
|
124
|
+
});
|
|
125
|
+
it('renders workspace PA as ok when all files exist', () => {
|
|
126
|
+
const snap = makeSnapshot({
|
|
127
|
+
paFiles: [
|
|
128
|
+
{ label: 'pa.md', exists: true },
|
|
129
|
+
{ label: 'pa-safety.md', exists: true },
|
|
130
|
+
],
|
|
131
|
+
});
|
|
132
|
+
const out = renderStatusReport(snap);
|
|
133
|
+
expect(out).toContain('Workspace PA: ok');
|
|
134
|
+
expect(out).toContain('pa.md: ok');
|
|
135
|
+
expect(out).toContain('pa-safety.md: ok');
|
|
136
|
+
});
|
|
137
|
+
it('renders workspace PA as DEGRADED when a file is missing', () => {
|
|
138
|
+
const snap = makeSnapshot({
|
|
139
|
+
paFiles: [
|
|
140
|
+
{ label: 'pa.md', exists: true },
|
|
141
|
+
{ label: 'pa-safety.md', exists: false },
|
|
142
|
+
],
|
|
143
|
+
});
|
|
144
|
+
const out = renderStatusReport(snap);
|
|
145
|
+
expect(out).toContain('Workspace PA: DEGRADED');
|
|
146
|
+
expect(out).toContain('pa-safety.md: MISSING');
|
|
147
|
+
});
|
|
148
|
+
it('renders workspace PA as DEGRADED when no files configured', () => {
|
|
149
|
+
const out = renderStatusReport(makeSnapshot({ paFiles: [] }));
|
|
150
|
+
expect(out).toContain('Workspace PA: DEGRADED');
|
|
151
|
+
expect(out).toContain('none configured');
|
|
152
|
+
});
|
|
153
|
+
it('uses custom bot display name', () => {
|
|
154
|
+
const out = renderStatusReport(makeSnapshot(), 'MyBot');
|
|
155
|
+
expect(out).toContain('MyBot Status');
|
|
156
|
+
expect(out).not.toContain('Discoclaw Status');
|
|
157
|
+
});
|
|
158
|
+
it('defaults to Discoclaw when no name provided', () => {
|
|
159
|
+
const out = renderStatusReport(makeSnapshot());
|
|
160
|
+
expect(out).toContain('Discoclaw Status');
|
|
161
|
+
});
|
|
162
|
+
it('wraps output in a fenced text code block', () => {
|
|
163
|
+
const out = renderStatusReport(makeSnapshot());
|
|
164
|
+
expect(out).toMatch(/^```text\n/);
|
|
165
|
+
expect(out).toMatch(/\n```$/);
|
|
166
|
+
});
|
|
167
|
+
it('renders next run in days for distant future', () => {
|
|
168
|
+
const farFuture = new Date(Date.now() + 2 * 24 * 60 * 60_000 + 3 * 60 * 60_000); // 2d 3h
|
|
169
|
+
const snap = makeSnapshot({
|
|
170
|
+
crons: [{ name: 'rare-job', schedule: '0 0 * * 0', nextRun: farFuture }],
|
|
171
|
+
});
|
|
172
|
+
const out = renderStatusReport(snap);
|
|
173
|
+
expect(out).toContain('rare-job: next=in 2d');
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
// ---------------------------------------------------------------------------
|
|
177
|
+
// collectStatusSnapshot
|
|
178
|
+
// ---------------------------------------------------------------------------
|
|
179
|
+
describe('collectStatusSnapshot', () => {
|
|
180
|
+
beforeEach(() => {
|
|
181
|
+
vi.spyOn(credentialCheck, 'checkDiscordToken').mockResolvedValue({
|
|
182
|
+
name: 'discord-token',
|
|
183
|
+
status: 'ok',
|
|
184
|
+
});
|
|
185
|
+
vi.spyOn(credentialCheck, 'checkOpenAiKey').mockResolvedValue({
|
|
186
|
+
name: 'openai-key',
|
|
187
|
+
status: 'skip',
|
|
188
|
+
});
|
|
189
|
+
vi.spyOn(credentialCheck, 'checkOpenRouterKey').mockResolvedValue({
|
|
190
|
+
name: 'openrouter-key',
|
|
191
|
+
status: 'skip',
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
afterEach(() => {
|
|
195
|
+
vi.restoreAllMocks();
|
|
196
|
+
});
|
|
197
|
+
function baseOpts(overrides = {}) {
|
|
198
|
+
return {
|
|
199
|
+
startedAt: Date.now() - 60_000,
|
|
200
|
+
lastMessageAt: null,
|
|
201
|
+
scheduler: null,
|
|
202
|
+
taskStore: null,
|
|
203
|
+
durableDataDir: null,
|
|
204
|
+
summaryDataDir: null,
|
|
205
|
+
discordToken: 'test-token',
|
|
206
|
+
paFilePaths: [],
|
|
207
|
+
...overrides,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
it('returns uptime based on startedAt', async () => {
|
|
211
|
+
const startedAt = Date.now() - 5000;
|
|
212
|
+
const snap = await collectStatusSnapshot(baseOpts({ startedAt }));
|
|
213
|
+
expect(snap.uptimeMs).toBeGreaterThanOrEqual(5000);
|
|
214
|
+
expect(snap.uptimeMs).toBeLessThan(10_000);
|
|
215
|
+
});
|
|
216
|
+
it('passes lastMessageAt through unchanged', async () => {
|
|
217
|
+
const ts = Date.now() - 1234;
|
|
218
|
+
const snap = await collectStatusSnapshot(baseOpts({ lastMessageAt: ts }));
|
|
219
|
+
expect(snap.lastMessageAt).toBe(ts);
|
|
220
|
+
});
|
|
221
|
+
it('returns empty crons when scheduler is null', async () => {
|
|
222
|
+
const snap = await collectStatusSnapshot(baseOpts({ scheduler: null }));
|
|
223
|
+
expect(snap.crons).toEqual([]);
|
|
224
|
+
});
|
|
225
|
+
it('delegates cron list to scheduler.listJobs()', async () => {
|
|
226
|
+
const mockJobs = [{ id: 'j1', name: 'test-job', schedule: '* * * * *', timezone: 'UTC', nextRun: null }];
|
|
227
|
+
const mockScheduler = { listJobs: vi.fn().mockReturnValue(mockJobs) };
|
|
228
|
+
const snap = await collectStatusSnapshot(baseOpts({ scheduler: mockScheduler }));
|
|
229
|
+
expect(mockScheduler.listJobs).toHaveBeenCalledOnce();
|
|
230
|
+
expect(snap.crons).toEqual(mockJobs);
|
|
231
|
+
});
|
|
232
|
+
it('returns 0 open tasks when taskStore is null', async () => {
|
|
233
|
+
const snap = await collectStatusSnapshot(baseOpts({ taskStore: null }));
|
|
234
|
+
expect(snap.openTaskCount).toBe(0);
|
|
235
|
+
});
|
|
236
|
+
it('delegates task count to taskStore.list()', async () => {
|
|
237
|
+
const mockStore = { list: vi.fn().mockReturnValue([{}, {}, {}]) };
|
|
238
|
+
const snap = await collectStatusSnapshot(baseOpts({ taskStore: mockStore }));
|
|
239
|
+
expect(snap.openTaskCount).toBe(3);
|
|
240
|
+
});
|
|
241
|
+
it('returns 0 durable items when durableDataDir is null', async () => {
|
|
242
|
+
const snap = await collectStatusSnapshot(baseOpts({ durableDataDir: null }));
|
|
243
|
+
expect(snap.durableItemCount).toBe(0);
|
|
244
|
+
});
|
|
245
|
+
it('returns 0 summary chars when summaryDataDir is null', async () => {
|
|
246
|
+
const snap = await collectStatusSnapshot(baseOpts({ summaryDataDir: null }));
|
|
247
|
+
expect(snap.rollingSummaryCharCount).toBe(0);
|
|
248
|
+
});
|
|
249
|
+
it('includes apiChecks from credential check functions', async () => {
|
|
250
|
+
const snap = await collectStatusSnapshot(baseOpts({ discordToken: 'tok', openaiApiKey: 'key' }));
|
|
251
|
+
expect(credentialCheck.checkDiscordToken).toHaveBeenCalledWith('tok');
|
|
252
|
+
expect(credentialCheck.checkOpenAiKey).toHaveBeenCalledWith({ apiKey: 'key', baseUrl: undefined });
|
|
253
|
+
expect(snap.apiChecks).toHaveLength(2);
|
|
254
|
+
expect(snap.apiChecks[0]).toEqual({ name: 'discord-token', status: 'ok' });
|
|
255
|
+
expect(snap.apiChecks[1]).toEqual({ name: 'openai-key', status: 'skip' });
|
|
256
|
+
});
|
|
257
|
+
it('passes openaiBaseUrl to checkOpenAiKey', async () => {
|
|
258
|
+
await collectStatusSnapshot(baseOpts({ openaiApiKey: 'key', openaiBaseUrl: 'https://example.com/v1' }));
|
|
259
|
+
expect(credentialCheck.checkOpenAiKey).toHaveBeenCalledWith({
|
|
260
|
+
apiKey: 'key',
|
|
261
|
+
baseUrl: 'https://example.com/v1',
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
it('returns paFiles with exists=false for missing paths', async () => {
|
|
265
|
+
const snap = await collectStatusSnapshot(baseOpts({
|
|
266
|
+
paFilePaths: [
|
|
267
|
+
{ label: 'pa.md', path: '/nonexistent/path/pa.md' },
|
|
268
|
+
{ label: 'pa-safety.md', path: '/nonexistent/path/pa-safety.md' },
|
|
269
|
+
],
|
|
270
|
+
}));
|
|
271
|
+
expect(snap.paFiles).toEqual([
|
|
272
|
+
{ label: 'pa.md', exists: false },
|
|
273
|
+
{ label: 'pa-safety.md', exists: false },
|
|
274
|
+
]);
|
|
275
|
+
});
|
|
276
|
+
it('handles API check failure gracefully — snapshot still resolves', async () => {
|
|
277
|
+
vi.spyOn(credentialCheck, 'checkDiscordToken').mockResolvedValue({
|
|
278
|
+
name: 'discord-token',
|
|
279
|
+
status: 'fail',
|
|
280
|
+
message: 'network error: timeout',
|
|
281
|
+
});
|
|
282
|
+
const snap = await collectStatusSnapshot(baseOpts());
|
|
283
|
+
expect(snap.apiChecks[0]?.status).toBe('fail');
|
|
284
|
+
});
|
|
285
|
+
it('handles non-existent durableDataDir without throwing', async () => {
|
|
286
|
+
const snap = await collectStatusSnapshot(baseOpts({ durableDataDir: '/tmp/discoclaw-test-nonexistent-durable-dir-xyz' }));
|
|
287
|
+
expect(snap.durableItemCount).toBe(0);
|
|
288
|
+
});
|
|
289
|
+
it('handles non-existent summaryDataDir without throwing', async () => {
|
|
290
|
+
const snap = await collectStatusSnapshot(baseOpts({ summaryDataDir: '/tmp/discoclaw-test-nonexistent-summary-dir-xyz' }));
|
|
291
|
+
expect(snap.rollingSummaryCharCount).toBe(0);
|
|
292
|
+
});
|
|
293
|
+
it('skips openai check and omits it from apiChecks when openai is not in activeProviders', async () => {
|
|
294
|
+
const snap = await collectStatusSnapshot(baseOpts({ activeProviders: new Set(['claude']) }));
|
|
295
|
+
expect(credentialCheck.checkOpenAiKey).not.toHaveBeenCalled();
|
|
296
|
+
expect(snap.apiChecks).toHaveLength(1);
|
|
297
|
+
expect(snap.apiChecks[0]).toEqual({ name: 'discord-token', status: 'ok' });
|
|
298
|
+
});
|
|
299
|
+
it('runs openai check when openai is in activeProviders', async () => {
|
|
300
|
+
const snap = await collectStatusSnapshot(baseOpts({ openaiApiKey: 'key', activeProviders: new Set(['openai']) }));
|
|
301
|
+
expect(credentialCheck.checkOpenAiKey).toHaveBeenCalledWith({ apiKey: 'key', baseUrl: undefined });
|
|
302
|
+
expect(snap.apiChecks).toHaveLength(2);
|
|
303
|
+
expect(snap.apiChecks[1]).toEqual({ name: 'openai-key', status: 'skip' });
|
|
304
|
+
});
|
|
305
|
+
it('skips openrouter check and omits it from apiChecks when openrouter is not in activeProviders', async () => {
|
|
306
|
+
const snap = await collectStatusSnapshot(baseOpts({ openrouterApiKey: 'sk-or-key', activeProviders: new Set(['claude']) }));
|
|
307
|
+
expect(credentialCheck.checkOpenRouterKey).not.toHaveBeenCalled();
|
|
308
|
+
expect(snap.apiChecks).toHaveLength(1);
|
|
309
|
+
expect(snap.apiChecks[0]).toEqual({ name: 'discord-token', status: 'ok' });
|
|
310
|
+
});
|
|
311
|
+
it('runs openrouter check when openrouter is in activeProviders', async () => {
|
|
312
|
+
const snap = await collectStatusSnapshot(baseOpts({ openrouterApiKey: 'sk-or-key', activeProviders: new Set(['openrouter']) }));
|
|
313
|
+
expect(credentialCheck.checkOpenRouterKey).toHaveBeenCalledWith({
|
|
314
|
+
apiKey: 'sk-or-key',
|
|
315
|
+
baseUrl: undefined,
|
|
316
|
+
});
|
|
317
|
+
expect(snap.apiChecks).toHaveLength(2);
|
|
318
|
+
expect(snap.apiChecks[1]).toEqual({ name: 'openrouter-key', status: 'skip' });
|
|
319
|
+
});
|
|
320
|
+
it('passes openrouterBaseUrl to checkOpenRouterKey', async () => {
|
|
321
|
+
await collectStatusSnapshot(baseOpts({
|
|
322
|
+
openrouterApiKey: 'sk-or-key',
|
|
323
|
+
openrouterBaseUrl: 'https://custom-or.example.com/v1',
|
|
324
|
+
activeProviders: new Set(['openrouter']),
|
|
325
|
+
}));
|
|
326
|
+
expect(credentialCheck.checkOpenRouterKey).toHaveBeenCalledWith({
|
|
327
|
+
apiKey: 'sk-or-key',
|
|
328
|
+
baseUrl: 'https://custom-or.example.com/v1',
|
|
329
|
+
});
|
|
330
|
+
});
|
|
331
|
+
it('runs both openai and openrouter checks when both are in activeProviders', async () => {
|
|
332
|
+
const snap = await collectStatusSnapshot(baseOpts({
|
|
333
|
+
openaiApiKey: 'sk-key',
|
|
334
|
+
openrouterApiKey: 'sk-or-key',
|
|
335
|
+
activeProviders: new Set(['openai', 'openrouter']),
|
|
336
|
+
}));
|
|
337
|
+
expect(credentialCheck.checkOpenAiKey).toHaveBeenCalled();
|
|
338
|
+
expect(credentialCheck.checkOpenRouterKey).toHaveBeenCalled();
|
|
339
|
+
expect(snap.apiChecks).toHaveLength(3);
|
|
340
|
+
});
|
|
341
|
+
});
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { ToolAwareQueue } from './tool-aware-queue.js';
|
|
2
|
+
import { selectStreamingOutput } from './output-utils.js';
|
|
3
|
+
import { NO_MENTIONS } from './allowed-mentions.js';
|
|
4
|
+
/** The faster edit interval used for streaming preview edits (matches normal message handler). */
|
|
5
|
+
const STREAMING_EDIT_INTERVAL_MS = 1250;
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// Factory
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
/**
|
|
10
|
+
* Creates a streaming progress controller for a Discord message.
|
|
11
|
+
*
|
|
12
|
+
* The controller wraps a `ToolAwareQueue` to track live tool activity and
|
|
13
|
+
* streaming text, driving periodic Discord message edits via
|
|
14
|
+
* `selectStreamingOutput`. Static progress text (phase transitions, etc.)
|
|
15
|
+
* is forwarded through `onProgress`, which also resets the queue so
|
|
16
|
+
* streaming state from the previous phase does not bleed into the next.
|
|
17
|
+
*
|
|
18
|
+
* @param progressReply The Discord message to edit in place.
|
|
19
|
+
* @param progressThrottleMs Minimum ms between static progress edits (forgeProgressThrottleMs).
|
|
20
|
+
*/
|
|
21
|
+
export function createStreamingProgress(progressReply, progressThrottleMs) {
|
|
22
|
+
// Streaming state driven by the ToolAwareQueue
|
|
23
|
+
let activityLabel = '';
|
|
24
|
+
let deltaText = '';
|
|
25
|
+
let finalText = '';
|
|
26
|
+
let statusTick = 0;
|
|
27
|
+
let lastStreamEditAt = 0;
|
|
28
|
+
let progressMessageGone = false;
|
|
29
|
+
const startedAt = Date.now();
|
|
30
|
+
// Static-progress throttle state (mirrors the existing onProgress pattern)
|
|
31
|
+
let lastStaticEditAt = 0;
|
|
32
|
+
// Current queue instance — replaced on each onProgress call
|
|
33
|
+
let queue = createQueue();
|
|
34
|
+
// Interval that drives streaming preview edits
|
|
35
|
+
const interval = setInterval(() => {
|
|
36
|
+
void maybeStreamEdit(false);
|
|
37
|
+
}, STREAMING_EDIT_INTERVAL_MS);
|
|
38
|
+
function createQueue() {
|
|
39
|
+
return new ToolAwareQueue((action) => {
|
|
40
|
+
if (action.type === 'show_activity') {
|
|
41
|
+
activityLabel = action.label;
|
|
42
|
+
deltaText = '';
|
|
43
|
+
}
|
|
44
|
+
else if (action.type === 'stream_text') {
|
|
45
|
+
deltaText += action.text;
|
|
46
|
+
}
|
|
47
|
+
else if (action.type === 'set_final') {
|
|
48
|
+
finalText = action.text;
|
|
49
|
+
deltaText = '';
|
|
50
|
+
activityLabel = '';
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
async function maybeStreamEdit(force) {
|
|
55
|
+
if (progressMessageGone)
|
|
56
|
+
return;
|
|
57
|
+
const now = Date.now();
|
|
58
|
+
if (!force && now - lastStreamEditAt < STREAMING_EDIT_INTERVAL_MS)
|
|
59
|
+
return;
|
|
60
|
+
// Only render when there is something streaming to show
|
|
61
|
+
if (!activityLabel && !deltaText && !finalText)
|
|
62
|
+
return;
|
|
63
|
+
lastStreamEditAt = now;
|
|
64
|
+
const content = selectStreamingOutput({
|
|
65
|
+
deltaText,
|
|
66
|
+
activityLabel,
|
|
67
|
+
finalText,
|
|
68
|
+
statusTick: statusTick++,
|
|
69
|
+
elapsedMs: now - startedAt,
|
|
70
|
+
});
|
|
71
|
+
try {
|
|
72
|
+
await progressReply.edit({ content, allowedMentions: NO_MENTIONS });
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
// ignore Discord edit errors during streaming
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
const onEvent = (evt) => {
|
|
79
|
+
queue.handleEvent(evt);
|
|
80
|
+
};
|
|
81
|
+
const onProgress = async (msg, opts) => {
|
|
82
|
+
if (progressMessageGone)
|
|
83
|
+
return;
|
|
84
|
+
const now = Date.now();
|
|
85
|
+
if (!opts?.force && now - lastStaticEditAt < progressThrottleMs)
|
|
86
|
+
return;
|
|
87
|
+
lastStaticEditAt = now;
|
|
88
|
+
// Reset streaming state for the new phase boundary
|
|
89
|
+
queue.dispose();
|
|
90
|
+
activityLabel = '';
|
|
91
|
+
deltaText = '';
|
|
92
|
+
finalText = '';
|
|
93
|
+
queue = createQueue();
|
|
94
|
+
try {
|
|
95
|
+
await progressReply.edit({ content: msg, allowedMentions: NO_MENTIONS });
|
|
96
|
+
}
|
|
97
|
+
catch (editErr) {
|
|
98
|
+
if (editErr?.code === 10008)
|
|
99
|
+
progressMessageGone = true;
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
const dispose = () => {
|
|
103
|
+
clearInterval(interval);
|
|
104
|
+
queue.dispose();
|
|
105
|
+
};
|
|
106
|
+
return { onEvent, onProgress, dispose };
|
|
107
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { createStreamingProgress } from './streaming-progress.js';
|
|
3
|
+
beforeEach(() => {
|
|
4
|
+
vi.useFakeTimers();
|
|
5
|
+
});
|
|
6
|
+
afterEach(() => {
|
|
7
|
+
vi.useRealTimers();
|
|
8
|
+
});
|
|
9
|
+
function makeMessage() {
|
|
10
|
+
const edits = [];
|
|
11
|
+
const message = {
|
|
12
|
+
edit: vi.fn(async (opts) => {
|
|
13
|
+
edits.push(opts.content);
|
|
14
|
+
}),
|
|
15
|
+
edits,
|
|
16
|
+
};
|
|
17
|
+
return message;
|
|
18
|
+
}
|
|
19
|
+
describe('createStreamingProgress', () => {
|
|
20
|
+
it('onProgress edits the message with the provided text', async () => {
|
|
21
|
+
const message = makeMessage();
|
|
22
|
+
const ctrl = createStreamingProgress(message, 0);
|
|
23
|
+
await ctrl.onProgress('Phase 1 running...');
|
|
24
|
+
expect(message.edit).toHaveBeenCalledTimes(1);
|
|
25
|
+
expect(message.edits[0]).toBe('Phase 1 running...');
|
|
26
|
+
ctrl.dispose();
|
|
27
|
+
});
|
|
28
|
+
it('onProgress respects the throttle when force is not set', async () => {
|
|
29
|
+
const message = makeMessage();
|
|
30
|
+
const ctrl = createStreamingProgress(message, 5000);
|
|
31
|
+
await ctrl.onProgress('first');
|
|
32
|
+
await ctrl.onProgress('second'); // within throttle window
|
|
33
|
+
expect(message.edit).toHaveBeenCalledTimes(1);
|
|
34
|
+
expect(message.edits[0]).toBe('first');
|
|
35
|
+
ctrl.dispose();
|
|
36
|
+
});
|
|
37
|
+
it('onProgress with force: true bypasses the throttle', async () => {
|
|
38
|
+
const message = makeMessage();
|
|
39
|
+
const ctrl = createStreamingProgress(message, 5000);
|
|
40
|
+
await ctrl.onProgress('first');
|
|
41
|
+
await ctrl.onProgress('second', { force: true });
|
|
42
|
+
expect(message.edit).toHaveBeenCalledTimes(2);
|
|
43
|
+
expect(message.edits[1]).toBe('second');
|
|
44
|
+
ctrl.dispose();
|
|
45
|
+
});
|
|
46
|
+
it('onEvent feeds events into the queue and keepalive interval drives edits', async () => {
|
|
47
|
+
const message = makeMessage();
|
|
48
|
+
const ctrl = createStreamingProgress(message, 5000);
|
|
49
|
+
await ctrl.onProgress('Phase start', { force: true });
|
|
50
|
+
// Simulate a tool use event so the TAQ emits show_activity
|
|
51
|
+
ctrl.onEvent({ type: 'tool_start', name: 'Read', input: '' });
|
|
52
|
+
// Advance past STREAMING_EDIT_INTERVAL_MS (1250ms)
|
|
53
|
+
await vi.advanceTimersByTimeAsync(1300);
|
|
54
|
+
// The keepalive interval should have fired and edited with streaming content
|
|
55
|
+
expect(message.edit.mock.calls.length).toBeGreaterThan(1);
|
|
56
|
+
ctrl.dispose();
|
|
57
|
+
});
|
|
58
|
+
it('onProgress resets the queue so stale streaming state is cleared', async () => {
|
|
59
|
+
const message = makeMessage();
|
|
60
|
+
const ctrl = createStreamingProgress(message, 0);
|
|
61
|
+
// Feed some streaming state
|
|
62
|
+
ctrl.onEvent({ type: 'text_delta', text: 'stale text' });
|
|
63
|
+
// New phase boundary
|
|
64
|
+
await ctrl.onProgress('Phase 2 starting...', { force: true });
|
|
65
|
+
// Advance interval — should not show stale text since queue was reset
|
|
66
|
+
await vi.advanceTimersByTimeAsync(1300);
|
|
67
|
+
const lastEdit = message.edits[message.edits.length - 1];
|
|
68
|
+
// After reset, streaming state is cleared; last edit is the progress message
|
|
69
|
+
// or empty streaming output — not stale text
|
|
70
|
+
expect(lastEdit).not.toContain('stale text');
|
|
71
|
+
ctrl.dispose();
|
|
72
|
+
});
|
|
73
|
+
it('dispose clears the keepalive interval', async () => {
|
|
74
|
+
const message = makeMessage();
|
|
75
|
+
const ctrl = createStreamingProgress(message, 0);
|
|
76
|
+
const editsBefore = message.edit.mock.calls.length;
|
|
77
|
+
ctrl.dispose();
|
|
78
|
+
await vi.advanceTimersByTimeAsync(5000);
|
|
79
|
+
// No additional edits from the interval after dispose
|
|
80
|
+
expect(message.edit.mock.calls.length).toBe(editsBefore);
|
|
81
|
+
});
|
|
82
|
+
it('handles deleted-message (code 10008) gracefully — stops editing', async () => {
|
|
83
|
+
const message = makeMessage();
|
|
84
|
+
message.edit.mockRejectedValueOnce(Object.assign(new Error('Unknown Message'), { code: 10008 }));
|
|
85
|
+
const ctrl = createStreamingProgress(message, 0);
|
|
86
|
+
// First call triggers the 10008 error
|
|
87
|
+
await ctrl.onProgress('Starting...');
|
|
88
|
+
// Second call should be silently skipped (progressMessageGone = true)
|
|
89
|
+
await ctrl.onProgress('Next step...', { force: true });
|
|
90
|
+
expect(message.edit).toHaveBeenCalledTimes(1);
|
|
91
|
+
ctrl.dispose();
|
|
92
|
+
});
|
|
93
|
+
});
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
function safeSessionKey(sessionKey) {
|
|
4
|
+
return sessionKey.replace(/[^a-zA-Z0-9:_-]+/g, '-');
|
|
5
|
+
}
|
|
6
|
+
export async function loadSummary(dir, sessionKey) {
|
|
7
|
+
const filePath = path.join(dir, `${safeSessionKey(sessionKey)}.json`);
|
|
8
|
+
try {
|
|
9
|
+
const raw = await fs.readFile(filePath, 'utf8');
|
|
10
|
+
const parsed = JSON.parse(raw);
|
|
11
|
+
if (parsed &&
|
|
12
|
+
typeof parsed === 'object' &&
|
|
13
|
+
'summary' in parsed &&
|
|
14
|
+
typeof parsed.summary === 'string') {
|
|
15
|
+
return parsed;
|
|
16
|
+
}
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
catch (err) {
|
|
20
|
+
const code = err.code;
|
|
21
|
+
if (code === 'ENOENT')
|
|
22
|
+
return null;
|
|
23
|
+
// Malformed file or other read error — treat as missing.
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
export async function saveSummary(dir, sessionKey, data) {
|
|
28
|
+
await fs.mkdir(dir, { recursive: true });
|
|
29
|
+
const filePath = path.join(dir, `${safeSessionKey(sessionKey)}.json`);
|
|
30
|
+
const tmp = `${filePath}.tmp.${process.pid}`;
|
|
31
|
+
await fs.writeFile(tmp, JSON.stringify(data, null, 2) + '\n', 'utf8');
|
|
32
|
+
await fs.rename(tmp, filePath);
|
|
33
|
+
}
|
|
34
|
+
const SUMMARIZE_PROMPT_TEMPLATE = `You are a conversation summarizer. Update the running summary below with the new exchange.
|
|
35
|
+
|
|
36
|
+
Rules:
|
|
37
|
+
- Keep the summary under {maxChars} characters.
|
|
38
|
+
- Drop filler; keep decisions, preferences, current focus, and key facts.
|
|
39
|
+
- Write in third person, present tense.
|
|
40
|
+
- Output ONLY the updated summary text, nothing else.
|
|
41
|
+
{taskStatusRule}
|
|
42
|
+
{previousSection}
|
|
43
|
+
{taskStatusSection}New exchange:
|
|
44
|
+
{recentExchange}
|
|
45
|
+
|
|
46
|
+
Updated summary:`;
|
|
47
|
+
export async function generateSummary(runtime, opts) {
|
|
48
|
+
try {
|
|
49
|
+
const previousSection = opts.previousSummary
|
|
50
|
+
? `Current summary:\n${opts.previousSummary}\n`
|
|
51
|
+
: 'Current summary:\n(none)\n';
|
|
52
|
+
const taskStatusRule = opts.taskStatusContext !== undefined
|
|
53
|
+
? '- A task status snapshot is provided below. Update the summary to reflect the current status of any referenced tasks. Tasks listed under "Recently closed" are now done — correct any stale open references to them. Tasks not present as active and not listed as recently closed are likely closed — remove or correct stale open-task references. If the snapshot notes it is truncated, only reconcile tasks explicitly listed.'
|
|
54
|
+
: '';
|
|
55
|
+
const taskStatusSection = opts.taskStatusContext !== undefined
|
|
56
|
+
? `Current task statuses:\n${opts.taskStatusContext}\n\n`
|
|
57
|
+
: '';
|
|
58
|
+
const prompt = SUMMARIZE_PROMPT_TEMPLATE
|
|
59
|
+
.replace('{maxChars}', String(opts.maxChars))
|
|
60
|
+
.replace('{taskStatusRule}', taskStatusRule)
|
|
61
|
+
.replace('{previousSection}', previousSection)
|
|
62
|
+
.replace('{taskStatusSection}', taskStatusSection)
|
|
63
|
+
.replace('{recentExchange}', opts.recentExchange);
|
|
64
|
+
let finalText = '';
|
|
65
|
+
let deltaText = '';
|
|
66
|
+
for await (const evt of runtime.invoke({
|
|
67
|
+
prompt,
|
|
68
|
+
model: opts.model,
|
|
69
|
+
cwd: opts.cwd,
|
|
70
|
+
tools: [],
|
|
71
|
+
timeoutMs: opts.timeoutMs,
|
|
72
|
+
})) {
|
|
73
|
+
if (evt.type === 'text_final') {
|
|
74
|
+
finalText = evt.text;
|
|
75
|
+
}
|
|
76
|
+
else if (evt.type === 'text_delta') {
|
|
77
|
+
deltaText += evt.text;
|
|
78
|
+
}
|
|
79
|
+
else if (evt.type === 'error') {
|
|
80
|
+
return opts.previousSummary ?? '';
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
const result = (finalText || deltaText).trim();
|
|
84
|
+
return result || (opts.previousSummary ?? '');
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
return opts.previousSummary ?? '';
|
|
88
|
+
}
|
|
89
|
+
}
|