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,180 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { ToolAwareQueue } from './tool-aware-queue.js';
|
|
3
|
+
beforeEach(() => {
|
|
4
|
+
vi.useFakeTimers();
|
|
5
|
+
});
|
|
6
|
+
afterEach(() => {
|
|
7
|
+
vi.useRealTimers();
|
|
8
|
+
});
|
|
9
|
+
function collect() {
|
|
10
|
+
const actions = [];
|
|
11
|
+
const emit = (a) => actions.push(a);
|
|
12
|
+
return { actions, emit };
|
|
13
|
+
}
|
|
14
|
+
describe('ToolAwareQueue', () => {
|
|
15
|
+
it('text-only response: text deltas buffered then streamed after flush delay', () => {
|
|
16
|
+
const { actions, emit } = collect();
|
|
17
|
+
const taq = new ToolAwareQueue(emit, { flushDelayMs: 2000, postToolDelayMs: 500 });
|
|
18
|
+
taq.handleEvent({ type: 'text_delta', text: 'Hello ' });
|
|
19
|
+
taq.handleEvent({ type: 'text_delta', text: 'world' });
|
|
20
|
+
// Before flush timer: nothing emitted yet.
|
|
21
|
+
expect(actions).toHaveLength(0);
|
|
22
|
+
// After flush timer fires.
|
|
23
|
+
vi.advanceTimersByTime(2000);
|
|
24
|
+
expect(actions).toHaveLength(1);
|
|
25
|
+
expect(actions[0]).toEqual({ type: 'stream_text', text: 'Hello world' });
|
|
26
|
+
// Further deltas stream directly.
|
|
27
|
+
taq.handleEvent({ type: 'text_delta', text: '!' });
|
|
28
|
+
expect(actions).toHaveLength(2);
|
|
29
|
+
expect(actions[1]).toEqual({ type: 'stream_text', text: '!' });
|
|
30
|
+
taq.dispose();
|
|
31
|
+
});
|
|
32
|
+
it('text then tool: narration discarded, activity shown', () => {
|
|
33
|
+
const { actions, emit } = collect();
|
|
34
|
+
const taq = new ToolAwareQueue(emit, { flushDelayMs: 2000, postToolDelayMs: 500 });
|
|
35
|
+
taq.handleEvent({ type: 'text_delta', text: 'Let me read the file...' });
|
|
36
|
+
taq.handleEvent({ type: 'tool_start', name: 'Read', input: { file_path: '/tmp/foo.ts' } });
|
|
37
|
+
// Narration was discarded, only show_activity emitted.
|
|
38
|
+
expect(actions).toHaveLength(1);
|
|
39
|
+
expect(actions[0]).toMatchObject({ type: 'show_activity' });
|
|
40
|
+
expect(actions[0].label).toContain('Reading');
|
|
41
|
+
taq.dispose();
|
|
42
|
+
});
|
|
43
|
+
it('tool then text: activity during tool, text streams after', () => {
|
|
44
|
+
const { actions, emit } = collect();
|
|
45
|
+
const taq = new ToolAwareQueue(emit, { flushDelayMs: 2000, postToolDelayMs: 500 });
|
|
46
|
+
taq.handleEvent({ type: 'tool_start', name: 'Bash' });
|
|
47
|
+
expect(actions).toHaveLength(1);
|
|
48
|
+
expect(actions[0]).toMatchObject({ type: 'show_activity', label: 'Running command...' });
|
|
49
|
+
taq.handleEvent({ type: 'tool_end', name: 'Bash', ok: true });
|
|
50
|
+
// After tool ends, start post-tool delay.
|
|
51
|
+
vi.advanceTimersByTime(500);
|
|
52
|
+
// Now text deltas should stream directly.
|
|
53
|
+
taq.handleEvent({ type: 'text_delta', text: 'The result is 42.' });
|
|
54
|
+
expect(actions).toHaveLength(2);
|
|
55
|
+
expect(actions[1]).toEqual({ type: 'stream_text', text: 'The result is 42.' });
|
|
56
|
+
taq.dispose();
|
|
57
|
+
});
|
|
58
|
+
it('multiple sequential tools: each shows its label, final text streams', () => {
|
|
59
|
+
const { actions, emit } = collect();
|
|
60
|
+
const taq = new ToolAwareQueue(emit, { flushDelayMs: 2000, postToolDelayMs: 500 });
|
|
61
|
+
// First tool.
|
|
62
|
+
taq.handleEvent({ type: 'tool_start', name: 'Read', input: { file_path: '/a/b.ts' } });
|
|
63
|
+
expect(actions).toHaveLength(1);
|
|
64
|
+
expect(actions[0]).toMatchObject({ type: 'show_activity' });
|
|
65
|
+
taq.handleEvent({ type: 'tool_end', name: 'Read', ok: true });
|
|
66
|
+
// Second tool arrives before post-tool flush.
|
|
67
|
+
taq.handleEvent({ type: 'tool_start', name: 'Bash' });
|
|
68
|
+
expect(actions).toHaveLength(2);
|
|
69
|
+
expect(actions[1]).toMatchObject({ type: 'show_activity', label: 'Running command...' });
|
|
70
|
+
taq.handleEvent({ type: 'tool_end', name: 'Bash', ok: true });
|
|
71
|
+
// Let post-tool flush fire.
|
|
72
|
+
vi.advanceTimersByTime(500);
|
|
73
|
+
// Final text.
|
|
74
|
+
taq.handleEvent({ type: 'text_final', text: 'Done!' });
|
|
75
|
+
expect(actions.find((a) => a.type === 'set_final')).toEqual({ type: 'set_final', text: 'Done!' });
|
|
76
|
+
taq.dispose();
|
|
77
|
+
});
|
|
78
|
+
it('text_final overrides everything', () => {
|
|
79
|
+
const { actions, emit } = collect();
|
|
80
|
+
const taq = new ToolAwareQueue(emit, { flushDelayMs: 2000, postToolDelayMs: 500 });
|
|
81
|
+
taq.handleEvent({ type: 'text_delta', text: 'buffered' });
|
|
82
|
+
taq.handleEvent({ type: 'text_final', text: 'The final answer.' });
|
|
83
|
+
// Timer should be cancelled, set_final should be emitted.
|
|
84
|
+
const finals = actions.filter((a) => a.type === 'set_final');
|
|
85
|
+
expect(finals).toHaveLength(1);
|
|
86
|
+
expect(finals[0]).toEqual({ type: 'set_final', text: 'The final answer.' });
|
|
87
|
+
// No stream_text for the buffered text.
|
|
88
|
+
expect(actions.filter((a) => a.type === 'stream_text')).toHaveLength(0);
|
|
89
|
+
taq.dispose();
|
|
90
|
+
});
|
|
91
|
+
it('flush timer fires correctly with fake timers', () => {
|
|
92
|
+
const { actions, emit } = collect();
|
|
93
|
+
const taq = new ToolAwareQueue(emit, { flushDelayMs: 3000, postToolDelayMs: 500 });
|
|
94
|
+
taq.handleEvent({ type: 'text_delta', text: 'test' });
|
|
95
|
+
// Not yet.
|
|
96
|
+
vi.advanceTimersByTime(2999);
|
|
97
|
+
expect(actions).toHaveLength(0);
|
|
98
|
+
// Now.
|
|
99
|
+
vi.advanceTimersByTime(1);
|
|
100
|
+
expect(actions).toHaveLength(1);
|
|
101
|
+
expect(actions[0]).toEqual({ type: 'stream_text', text: 'test' });
|
|
102
|
+
taq.dispose();
|
|
103
|
+
});
|
|
104
|
+
it('dispose() cancels timers', () => {
|
|
105
|
+
const { actions, emit } = collect();
|
|
106
|
+
const taq = new ToolAwareQueue(emit, { flushDelayMs: 2000, postToolDelayMs: 500 });
|
|
107
|
+
taq.handleEvent({ type: 'text_delta', text: 'will be lost' });
|
|
108
|
+
taq.dispose();
|
|
109
|
+
vi.advanceTimersByTime(5000);
|
|
110
|
+
expect(actions).toHaveLength(0);
|
|
111
|
+
});
|
|
112
|
+
it('post-tool delay prevents flashing narration between tools', () => {
|
|
113
|
+
const { actions, emit } = collect();
|
|
114
|
+
const taq = new ToolAwareQueue(emit, { flushDelayMs: 2000, postToolDelayMs: 500 });
|
|
115
|
+
taq.handleEvent({ type: 'tool_start', name: 'Read' });
|
|
116
|
+
taq.handleEvent({ type: 'tool_end', name: 'Read', ok: true });
|
|
117
|
+
// Text delta during post-tool delay window.
|
|
118
|
+
taq.handleEvent({ type: 'text_delta', text: 'Now let me run...' });
|
|
119
|
+
// Before post-tool delay fires, second tool starts.
|
|
120
|
+
vi.advanceTimersByTime(200);
|
|
121
|
+
taq.handleEvent({ type: 'tool_start', name: 'Bash' });
|
|
122
|
+
// The narration text should not have been streamed.
|
|
123
|
+
expect(actions.filter((a) => a.type === 'stream_text')).toHaveLength(0);
|
|
124
|
+
// Two show_activity actions.
|
|
125
|
+
expect(actions.filter((a) => a.type === 'show_activity')).toHaveLength(2);
|
|
126
|
+
taq.dispose();
|
|
127
|
+
});
|
|
128
|
+
it('events after dispose() are silently ignored', () => {
|
|
129
|
+
const { actions, emit } = collect();
|
|
130
|
+
const taq = new ToolAwareQueue(emit, { flushDelayMs: 2000, postToolDelayMs: 500 });
|
|
131
|
+
taq.handleEvent({ type: 'text_delta', text: 'before' });
|
|
132
|
+
taq.dispose();
|
|
133
|
+
const countAfterDispose = actions.length;
|
|
134
|
+
// These should all be silently dropped.
|
|
135
|
+
taq.handleEvent({ type: 'text_delta', text: 'after' });
|
|
136
|
+
taq.handleEvent({ type: 'tool_start', name: 'Read' });
|
|
137
|
+
taq.handleEvent({ type: 'tool_end', name: 'Read', ok: true });
|
|
138
|
+
taq.handleEvent({ type: 'text_final', text: 'final' });
|
|
139
|
+
vi.advanceTimersByTime(5000);
|
|
140
|
+
expect(actions).toHaveLength(countAfterDispose);
|
|
141
|
+
});
|
|
142
|
+
it('tool_end without prior tool_start does not crash', () => {
|
|
143
|
+
const { actions, emit } = collect();
|
|
144
|
+
const taq = new ToolAwareQueue(emit, { flushDelayMs: 2000, postToolDelayMs: 500 });
|
|
145
|
+
// Orphan tool_end — should be silently ignored (state is idle, not tool_active).
|
|
146
|
+
taq.handleEvent({ type: 'tool_end', name: 'Bash', ok: true });
|
|
147
|
+
// Queue should still function normally after the orphan.
|
|
148
|
+
taq.handleEvent({ type: 'text_delta', text: 'hello' });
|
|
149
|
+
vi.advanceTimersByTime(2000);
|
|
150
|
+
expect(actions).toHaveLength(1);
|
|
151
|
+
expect(actions[0]).toEqual({ type: 'stream_text', text: 'hello' });
|
|
152
|
+
taq.dispose();
|
|
153
|
+
});
|
|
154
|
+
it('multiple text_final events: only first is emitted as set_final', () => {
|
|
155
|
+
const { actions, emit } = collect();
|
|
156
|
+
const taq = new ToolAwareQueue(emit, { flushDelayMs: 2000, postToolDelayMs: 500 });
|
|
157
|
+
taq.handleEvent({ type: 'text_final', text: 'first final' });
|
|
158
|
+
taq.handleEvent({ type: 'text_final', text: 'second final' });
|
|
159
|
+
const finals = actions.filter((a) => a.type === 'set_final');
|
|
160
|
+
expect(finals).toHaveLength(2);
|
|
161
|
+
expect(finals[0]).toEqual({ type: 'set_final', text: 'first final' });
|
|
162
|
+
// Second text_final still emits (no crash), but first is the meaningful one.
|
|
163
|
+
expect(finals[1]).toEqual({ type: 'set_final', text: 'second final' });
|
|
164
|
+
taq.dispose();
|
|
165
|
+
});
|
|
166
|
+
it('text deltas during tool_active are discarded on next tool_start', () => {
|
|
167
|
+
const { actions, emit } = collect();
|
|
168
|
+
const taq = new ToolAwareQueue(emit, { flushDelayMs: 2000, postToolDelayMs: 500 });
|
|
169
|
+
taq.handleEvent({ type: 'tool_start', name: 'Read' });
|
|
170
|
+
taq.handleEvent({ type: 'text_delta', text: 'I found...' });
|
|
171
|
+
taq.handleEvent({ type: 'tool_end', name: 'Read', ok: true });
|
|
172
|
+
// Tool end transitions to buffering_text with empty buffer.
|
|
173
|
+
// But the text delta during tool_active was in buffer...
|
|
174
|
+
// Then a new tool arrives before the flush timer.
|
|
175
|
+
taq.handleEvent({ type: 'tool_start', name: 'Bash' });
|
|
176
|
+
// No stream_text should have been emitted.
|
|
177
|
+
expect(actions.filter((a) => a.type === 'stream_text')).toHaveLength(0);
|
|
178
|
+
taq.dispose();
|
|
179
|
+
});
|
|
180
|
+
});
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { writeShutdownContext } from './shutdown-context.js';
|
|
3
|
+
import { getRestartCmdArgs } from './restart-command.js';
|
|
4
|
+
import { getActiveOrchestrator, getRunningPlanIds } from './forge-plan-registry.js';
|
|
5
|
+
export function parseUpdateCommand(content) {
|
|
6
|
+
const normalized = content.trim().toLowerCase().replace(/\s+/g, ' ');
|
|
7
|
+
if (normalized === '!update')
|
|
8
|
+
return { action: 'check' };
|
|
9
|
+
if (normalized === '!update apply')
|
|
10
|
+
return { action: 'apply' };
|
|
11
|
+
if (normalized === '!update help')
|
|
12
|
+
return { action: 'help' };
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
function run(cmd, args, opts = {}) {
|
|
16
|
+
return new Promise((resolve) => {
|
|
17
|
+
execFile(cmd, args, {
|
|
18
|
+
timeout: opts.timeout ?? 60_000,
|
|
19
|
+
cwd: opts.cwd,
|
|
20
|
+
env: opts.env ?? process.env,
|
|
21
|
+
}, (err, stdout, stderr) => {
|
|
22
|
+
const exitCode = err ? err.code ?? null : 0;
|
|
23
|
+
resolve({
|
|
24
|
+
stdout: String(stdout ?? ''),
|
|
25
|
+
stderr: String(stderr ?? ''),
|
|
26
|
+
exitCode: typeof exitCode === 'number' ? exitCode : null,
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
const GIT_ENV = { ...process.env, GIT_TERMINAL_PROMPT: '0' };
|
|
32
|
+
export async function handleUpdateCommand(cmd, opts = {}) {
|
|
33
|
+
const { log, dataDir, userId, restartCmd, projectCwd, onProgress } = opts;
|
|
34
|
+
const progress = (msg) => {
|
|
35
|
+
onProgress?.(msg);
|
|
36
|
+
log?.info({}, `update-command: ${msg}`);
|
|
37
|
+
};
|
|
38
|
+
if (cmd.action === 'help') {
|
|
39
|
+
return {
|
|
40
|
+
reply: [
|
|
41
|
+
'**!update commands:**',
|
|
42
|
+
'- `!update` — check for available updates from main',
|
|
43
|
+
'- `!update apply` — pull, install, build, and restart',
|
|
44
|
+
'- `!update help` — this message',
|
|
45
|
+
].join('\n'),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
if (cmd.action === 'check') {
|
|
49
|
+
progress('Fetching from origin...');
|
|
50
|
+
const fetch = await run('git', ['fetch'], { cwd: projectCwd, env: GIT_ENV, timeout: 60_000 });
|
|
51
|
+
if (fetch.exitCode !== 0) {
|
|
52
|
+
const detail = (fetch.stderr || fetch.stdout).trim().slice(0, 500);
|
|
53
|
+
return { reply: `Failed to fetch from origin: \`${detail}\`` };
|
|
54
|
+
}
|
|
55
|
+
const logResult = await run('git', ['log', 'HEAD..origin/main', '--oneline'], { cwd: projectCwd, env: GIT_ENV, timeout: 60_000 });
|
|
56
|
+
const commits = logResult.stdout.trim();
|
|
57
|
+
if (!commits) {
|
|
58
|
+
return { reply: 'Already up to date.' };
|
|
59
|
+
}
|
|
60
|
+
return { reply: `Available updates from main:\n\`\`\`\n${commits.slice(0, 1800)}\n\`\`\`` };
|
|
61
|
+
}
|
|
62
|
+
// action === 'apply'
|
|
63
|
+
// 1. Check for active work.
|
|
64
|
+
const activeOrch = getActiveOrchestrator();
|
|
65
|
+
if (activeOrch?.isRunning) {
|
|
66
|
+
return { reply: 'Cannot update: a forge run is in progress. Wait for it to finish or cancel it first.' };
|
|
67
|
+
}
|
|
68
|
+
if (getRunningPlanIds().size > 0) {
|
|
69
|
+
return { reply: 'Cannot update: a plan run is in progress. Wait for it to finish first.' };
|
|
70
|
+
}
|
|
71
|
+
// 2. Check for dirty tree.
|
|
72
|
+
progress('Checking working tree...');
|
|
73
|
+
const statusResult = await run('git', ['status', '--porcelain'], { cwd: projectCwd, env: GIT_ENV, timeout: 60_000 });
|
|
74
|
+
if (statusResult.stdout.trim()) {
|
|
75
|
+
return { reply: 'Cannot update: working tree has uncommitted changes. Stash or commit them first.' };
|
|
76
|
+
}
|
|
77
|
+
// 3. git pull
|
|
78
|
+
progress('Pulling from origin/main...');
|
|
79
|
+
const pull = await run('git', ['pull'], { cwd: projectCwd, env: GIT_ENV, timeout: 60_000 });
|
|
80
|
+
if (pull.exitCode !== 0) {
|
|
81
|
+
const detail = (pull.stderr || pull.stdout).trim().slice(0, 500);
|
|
82
|
+
return { reply: `\`git pull\` failed:\n\`\`\`\n${detail}\n\`\`\`` };
|
|
83
|
+
}
|
|
84
|
+
// 4. pnpm install
|
|
85
|
+
progress('Running pnpm install...');
|
|
86
|
+
const install = await run('pnpm', ['install'], { cwd: projectCwd, timeout: 120_000 });
|
|
87
|
+
if (install.exitCode !== 0) {
|
|
88
|
+
const detail = (install.stderr || install.stdout).trim().slice(0, 500);
|
|
89
|
+
return { reply: `\`pnpm install\` failed:\n\`\`\`\n${detail}\n\`\`\`` };
|
|
90
|
+
}
|
|
91
|
+
// 5. pnpm build
|
|
92
|
+
progress('Running pnpm build...');
|
|
93
|
+
const build = await run('pnpm', ['build'], { cwd: projectCwd, timeout: 120_000 });
|
|
94
|
+
if (build.exitCode !== 0) {
|
|
95
|
+
const detail = (build.stderr || build.stdout).trim().slice(0, 500);
|
|
96
|
+
return { reply: `\`pnpm build\` failed:\n\`\`\`\n${detail}\n\`\`\`` };
|
|
97
|
+
}
|
|
98
|
+
return {
|
|
99
|
+
reply: 'Update complete. Restarting discoclaw... back in a moment.',
|
|
100
|
+
deferred: () => {
|
|
101
|
+
if (dataDir) {
|
|
102
|
+
const ctx = {
|
|
103
|
+
reason: 'restart-command',
|
|
104
|
+
message: 'User requested via !update apply',
|
|
105
|
+
timestamp: new Date().toISOString(),
|
|
106
|
+
requestedBy: userId,
|
|
107
|
+
};
|
|
108
|
+
writeShutdownContext(dataDir, ctx).catch((err) => {
|
|
109
|
+
log?.warn({ err }, 'update-command: failed to write shutdown context');
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
if (restartCmd) {
|
|
113
|
+
execFile('/bin/sh', ['-c', restartCmd], (err) => {
|
|
114
|
+
if (err)
|
|
115
|
+
log?.error({ err }, 'update-command: restart failed');
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
else {
|
|
119
|
+
const [restartBin, restartArgList] = getRestartCmdArgs();
|
|
120
|
+
execFile(restartBin, restartArgList, (err) => {
|
|
121
|
+
if (err)
|
|
122
|
+
log?.error({ err }, 'update-command: restart failed');
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
}
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { parseUpdateCommand, handleUpdateCommand } from './update-command.js';
|
|
3
|
+
import * as shutdownCtx from './shutdown-context.js';
|
|
4
|
+
import * as registry from './forge-plan-registry.js';
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// Mock node:child_process
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
vi.mock('node:child_process', () => ({
|
|
9
|
+
execFile: vi.fn((cmd, args, optsOrCb, maybeCb) => {
|
|
10
|
+
const cb = typeof optsOrCb === 'function' ? optsOrCb : maybeCb;
|
|
11
|
+
// Default: all commands succeed with empty output.
|
|
12
|
+
if (cb)
|
|
13
|
+
cb(null, '', '');
|
|
14
|
+
}),
|
|
15
|
+
}));
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// parseUpdateCommand
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
describe('parseUpdateCommand', () => {
|
|
20
|
+
it('parses !update as check action', () => {
|
|
21
|
+
expect(parseUpdateCommand('!update')).toEqual({ action: 'check' });
|
|
22
|
+
});
|
|
23
|
+
it('parses !update apply', () => {
|
|
24
|
+
expect(parseUpdateCommand('!update apply')).toEqual({ action: 'apply' });
|
|
25
|
+
});
|
|
26
|
+
it('parses !update help', () => {
|
|
27
|
+
expect(parseUpdateCommand('!update help')).toEqual({ action: 'help' });
|
|
28
|
+
});
|
|
29
|
+
it('returns null for non-update messages', () => {
|
|
30
|
+
expect(parseUpdateCommand('hello')).toBeNull();
|
|
31
|
+
expect(parseUpdateCommand('!restart')).toBeNull();
|
|
32
|
+
expect(parseUpdateCommand('!updating')).toBeNull();
|
|
33
|
+
expect(parseUpdateCommand('!update unknown')).toBeNull();
|
|
34
|
+
});
|
|
35
|
+
it('is case-insensitive', () => {
|
|
36
|
+
expect(parseUpdateCommand('!UPDATE')).toEqual({ action: 'check' });
|
|
37
|
+
expect(parseUpdateCommand('!Update Apply')).toEqual({ action: 'apply' });
|
|
38
|
+
expect(parseUpdateCommand('!Update Help')).toEqual({ action: 'help' });
|
|
39
|
+
});
|
|
40
|
+
it('handles surrounding whitespace', () => {
|
|
41
|
+
expect(parseUpdateCommand(' !update ')).toEqual({ action: 'check' });
|
|
42
|
+
expect(parseUpdateCommand(' !update apply ')).toEqual({ action: 'apply' });
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
// handleUpdateCommand — help
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
describe('handleUpdateCommand: help', () => {
|
|
49
|
+
it('returns usage text without calling execFile', async () => {
|
|
50
|
+
const { execFile } = await import('node:child_process');
|
|
51
|
+
vi.clearAllMocks();
|
|
52
|
+
const result = await handleUpdateCommand({ action: 'help' });
|
|
53
|
+
expect(result.reply).toContain('!update commands');
|
|
54
|
+
expect(result.reply).toContain('!update apply');
|
|
55
|
+
expect(result.deferred).toBeUndefined();
|
|
56
|
+
expect(execFile).not.toHaveBeenCalled();
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
// handleUpdateCommand — check
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
describe('handleUpdateCommand: check', () => {
|
|
63
|
+
beforeEach(() => {
|
|
64
|
+
vi.clearAllMocks();
|
|
65
|
+
});
|
|
66
|
+
it('reports "up to date" when git log returns empty output', async () => {
|
|
67
|
+
const { execFile } = await import('node:child_process');
|
|
68
|
+
execFile.mockImplementation((cmd, args, opts, cb) => {
|
|
69
|
+
cb(null, '', '');
|
|
70
|
+
});
|
|
71
|
+
const result = await handleUpdateCommand({ action: 'check' });
|
|
72
|
+
expect(result.reply).toBe('Already up to date.');
|
|
73
|
+
expect(result.deferred).toBeUndefined();
|
|
74
|
+
});
|
|
75
|
+
it('lists available commits when git log has output', async () => {
|
|
76
|
+
const { execFile } = await import('node:child_process');
|
|
77
|
+
execFile.mockImplementation((cmd, args, opts, cb) => {
|
|
78
|
+
if (args.includes('log')) {
|
|
79
|
+
cb(null, 'abc1234 Fix a bug\ndef5678 Add feature\n', '');
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
cb(null, '', '');
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
const result = await handleUpdateCommand({ action: 'check' });
|
|
86
|
+
expect(result.reply).toContain('Available updates from main');
|
|
87
|
+
expect(result.reply).toContain('abc1234');
|
|
88
|
+
expect(result.reply).toContain('def5678');
|
|
89
|
+
});
|
|
90
|
+
it('reports error when git fetch fails', async () => {
|
|
91
|
+
const { execFile } = await import('node:child_process');
|
|
92
|
+
execFile.mockImplementation((cmd, args, opts, cb) => {
|
|
93
|
+
if (args.includes('fetch')) {
|
|
94
|
+
const err = new Error('network error');
|
|
95
|
+
err.code = 1;
|
|
96
|
+
cb(err, '', 'network error');
|
|
97
|
+
}
|
|
98
|
+
else {
|
|
99
|
+
cb(null, '', '');
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
const result = await handleUpdateCommand({ action: 'check' });
|
|
103
|
+
expect(result.reply).toContain('Failed to fetch');
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
// handleUpdateCommand — apply
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
describe('handleUpdateCommand: apply', () => {
|
|
110
|
+
beforeEach(() => {
|
|
111
|
+
vi.clearAllMocks();
|
|
112
|
+
vi.spyOn(registry, 'getActiveOrchestrator').mockReturnValue(null);
|
|
113
|
+
vi.spyOn(registry, 'getRunningPlanIds').mockReturnValue(new Set());
|
|
114
|
+
});
|
|
115
|
+
function mockAllSuccess() {
|
|
116
|
+
return vi.fn((cmd, args, optsOrCb, maybeCb) => {
|
|
117
|
+
const cb = typeof optsOrCb === 'function' ? optsOrCb : maybeCb;
|
|
118
|
+
if (cb)
|
|
119
|
+
cb(null, '', '');
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
it('aborts when a forge run is active', async () => {
|
|
123
|
+
vi.spyOn(registry, 'getActiveOrchestrator').mockReturnValue({ isRunning: true });
|
|
124
|
+
const result = await handleUpdateCommand({ action: 'apply' });
|
|
125
|
+
expect(result.reply).toContain('forge run is in progress');
|
|
126
|
+
expect(result.deferred).toBeUndefined();
|
|
127
|
+
});
|
|
128
|
+
it('aborts when a plan run is active', async () => {
|
|
129
|
+
vi.spyOn(registry, 'getRunningPlanIds').mockReturnValue(new Set(['plan-001']));
|
|
130
|
+
const result = await handleUpdateCommand({ action: 'apply' });
|
|
131
|
+
expect(result.reply).toContain('plan run is in progress');
|
|
132
|
+
expect(result.deferred).toBeUndefined();
|
|
133
|
+
});
|
|
134
|
+
it('aborts when working tree is dirty', async () => {
|
|
135
|
+
const { execFile } = await import('node:child_process');
|
|
136
|
+
execFile.mockImplementation((cmd, args, opts, cb) => {
|
|
137
|
+
if (args.includes('--porcelain')) {
|
|
138
|
+
cb(null, 'M src/index.ts\n', '');
|
|
139
|
+
}
|
|
140
|
+
else {
|
|
141
|
+
cb(null, '', '');
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
const result = await handleUpdateCommand({ action: 'apply' });
|
|
145
|
+
expect(result.reply).toContain('uncommitted changes');
|
|
146
|
+
expect(result.deferred).toBeUndefined();
|
|
147
|
+
});
|
|
148
|
+
it('aborts and reports error when git pull fails', async () => {
|
|
149
|
+
const { execFile } = await import('node:child_process');
|
|
150
|
+
execFile.mockImplementation((cmd, args, opts, cb) => {
|
|
151
|
+
if (args.includes('pull')) {
|
|
152
|
+
const err = new Error('merge conflict');
|
|
153
|
+
err.code = 1;
|
|
154
|
+
cb(err, '', 'merge conflict');
|
|
155
|
+
}
|
|
156
|
+
else {
|
|
157
|
+
cb(null, '', '');
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
const result = await handleUpdateCommand({ action: 'apply' });
|
|
161
|
+
expect(result.reply).toContain('git pull');
|
|
162
|
+
expect(result.reply).toContain('failed');
|
|
163
|
+
expect(result.deferred).toBeUndefined();
|
|
164
|
+
});
|
|
165
|
+
it('aborts and reports error when pnpm install fails', async () => {
|
|
166
|
+
const { execFile } = await import('node:child_process');
|
|
167
|
+
execFile.mockImplementation((cmd, args, opts, cb) => {
|
|
168
|
+
if (cmd === 'pnpm' && args.includes('install')) {
|
|
169
|
+
const err = new Error('install failed');
|
|
170
|
+
err.code = 1;
|
|
171
|
+
cb(err, '', 'install failed');
|
|
172
|
+
}
|
|
173
|
+
else {
|
|
174
|
+
cb(null, '', '');
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
const result = await handleUpdateCommand({ action: 'apply' });
|
|
178
|
+
expect(result.reply).toContain('pnpm install');
|
|
179
|
+
expect(result.reply).toContain('failed');
|
|
180
|
+
expect(result.deferred).toBeUndefined();
|
|
181
|
+
});
|
|
182
|
+
it('aborts and reports error when pnpm build fails', async () => {
|
|
183
|
+
const { execFile } = await import('node:child_process');
|
|
184
|
+
execFile.mockImplementation((cmd, args, opts, cb) => {
|
|
185
|
+
if (cmd === 'pnpm' && args.includes('build')) {
|
|
186
|
+
const err = new Error('build failed');
|
|
187
|
+
err.code = 1;
|
|
188
|
+
cb(err, '', 'build failed');
|
|
189
|
+
}
|
|
190
|
+
else {
|
|
191
|
+
cb(null, '', '');
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
const result = await handleUpdateCommand({ action: 'apply' });
|
|
195
|
+
expect(result.reply).toContain('pnpm build');
|
|
196
|
+
expect(result.reply).toContain('failed');
|
|
197
|
+
expect(result.deferred).toBeUndefined();
|
|
198
|
+
});
|
|
199
|
+
it('returns a deferred restart function on full success', async () => {
|
|
200
|
+
const { execFile } = await import('node:child_process');
|
|
201
|
+
execFile.mockImplementation(mockAllSuccess());
|
|
202
|
+
const result = await handleUpdateCommand({ action: 'apply' });
|
|
203
|
+
expect(result.reply).toContain('Restarting discoclaw');
|
|
204
|
+
expect(typeof result.deferred).toBe('function');
|
|
205
|
+
});
|
|
206
|
+
it('deferred writes shutdown context before restarting', async () => {
|
|
207
|
+
const { execFile } = await import('node:child_process');
|
|
208
|
+
execFile.mockImplementation(mockAllSuccess());
|
|
209
|
+
const spy = vi.spyOn(shutdownCtx, 'writeShutdownContext').mockResolvedValue();
|
|
210
|
+
const result = await handleUpdateCommand({ action: 'apply' }, { dataDir: '/tmp/test', userId: '999' });
|
|
211
|
+
expect(spy).not.toHaveBeenCalled();
|
|
212
|
+
result.deferred();
|
|
213
|
+
expect(spy).toHaveBeenCalledOnce();
|
|
214
|
+
expect(spy.mock.calls[0][1]).toMatchObject({
|
|
215
|
+
reason: 'restart-command',
|
|
216
|
+
requestedBy: '999',
|
|
217
|
+
});
|
|
218
|
+
spy.mockRestore();
|
|
219
|
+
});
|
|
220
|
+
it('deferred uses systemctl restart by default', async () => {
|
|
221
|
+
const { execFile } = await import('node:child_process');
|
|
222
|
+
const mock = mockAllSuccess();
|
|
223
|
+
execFile.mockImplementation(mock);
|
|
224
|
+
const result = await handleUpdateCommand({ action: 'apply' });
|
|
225
|
+
result.deferred();
|
|
226
|
+
// Find the restart call — it's the last execFile call after the pipeline.
|
|
227
|
+
const calls = execFile.mock.calls;
|
|
228
|
+
const restartCall = calls.find(([cmd, args]) => cmd === 'systemctl' && args.includes('restart'));
|
|
229
|
+
expect(restartCall).toBeDefined();
|
|
230
|
+
expect(restartCall[1]).toEqual(['--user', 'restart', 'discoclaw']);
|
|
231
|
+
});
|
|
232
|
+
it('deferred uses launchctl kickstart on macOS', async () => {
|
|
233
|
+
const { execFile } = await import('node:child_process');
|
|
234
|
+
const mock = mockAllSuccess();
|
|
235
|
+
execFile.mockImplementation(mock);
|
|
236
|
+
const originalPlatform = process.platform;
|
|
237
|
+
Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true });
|
|
238
|
+
const originalGetuid = process.getuid;
|
|
239
|
+
process.getuid = () => 501;
|
|
240
|
+
try {
|
|
241
|
+
const result = await handleUpdateCommand({ action: 'apply' });
|
|
242
|
+
result.deferred();
|
|
243
|
+
const calls = execFile.mock.calls;
|
|
244
|
+
const restartCall = calls.find(([cmd, args]) => cmd === 'launchctl' && args.includes('kickstart'));
|
|
245
|
+
expect(restartCall).toBeDefined();
|
|
246
|
+
expect(restartCall[1]).toContain('-k');
|
|
247
|
+
expect(restartCall[1].some((a) => a.includes('com.discoclaw.agent'))).toBe(true);
|
|
248
|
+
}
|
|
249
|
+
finally {
|
|
250
|
+
Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true });
|
|
251
|
+
process.getuid = originalGetuid;
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
it('deferred uses restartCmd via /bin/sh when provided', async () => {
|
|
255
|
+
const { execFile } = await import('node:child_process');
|
|
256
|
+
execFile.mockImplementation(mockAllSuccess());
|
|
257
|
+
const result = await handleUpdateCommand({ action: 'apply' }, { restartCmd: 'sudo systemctl restart discoclaw' });
|
|
258
|
+
result.deferred();
|
|
259
|
+
const calls = execFile.mock.calls;
|
|
260
|
+
const shCall = calls.find(([cmd]) => cmd === '/bin/sh');
|
|
261
|
+
expect(shCall).toBeDefined();
|
|
262
|
+
expect(shCall[1]).toEqual(['-c', 'sudo systemctl restart discoclaw']);
|
|
263
|
+
});
|
|
264
|
+
it('calls onProgress callback for each step', async () => {
|
|
265
|
+
const { execFile } = await import('node:child_process');
|
|
266
|
+
execFile.mockImplementation(mockAllSuccess());
|
|
267
|
+
const progress = [];
|
|
268
|
+
await handleUpdateCommand({ action: 'apply' }, { onProgress: (msg) => progress.push(msg) });
|
|
269
|
+
expect(progress.length).toBeGreaterThanOrEqual(4);
|
|
270
|
+
expect(progress.some((m) => m.includes('working tree') || m.includes('Checking'))).toBe(true);
|
|
271
|
+
expect(progress.some((m) => m.includes('pull') || m.includes('Pulling'))).toBe(true);
|
|
272
|
+
expect(progress.some((m) => m.includes('install'))).toBe(true);
|
|
273
|
+
expect(progress.some((m) => m.includes('build'))).toBe(true);
|
|
274
|
+
});
|
|
275
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export function messageContentIntentHint() {
|
|
2
|
+
return ('Discord is delivering empty message content. Enable Message Content Intent in the Discord Developer Portal ' +
|
|
3
|
+
'(Application -> Bot -> Privileged Gateway Intents), then restart the bot.');
|
|
4
|
+
}
|
|
5
|
+
export function mapRuntimeErrorToUserMessage(raw) {
|
|
6
|
+
const msg = String(raw ?? '').trim();
|
|
7
|
+
const lc = msg.toLowerCase();
|
|
8
|
+
const mentionsClaude = lc.includes('claude');
|
|
9
|
+
if (lc.includes('timed out')) {
|
|
10
|
+
return 'The runtime timed out before finishing. Try a smaller request or increase RUNTIME_TIMEOUT_MS.';
|
|
11
|
+
}
|
|
12
|
+
if (lc.includes('missing permissions') || lc.includes('missing access')) {
|
|
13
|
+
return ('Discord denied this action due to missing permissions/access. ' +
|
|
14
|
+
'Update the bot role permissions in Server Settings -> Roles, then retry.');
|
|
15
|
+
}
|
|
16
|
+
if (mentionsClaude && (lc.includes('not found') || lc.includes('enoent') || lc.includes('spawn'))) {
|
|
17
|
+
return 'Claude CLI was not found. Install it and set CLAUDE_BIN (or fix PATH), then restart.';
|
|
18
|
+
}
|
|
19
|
+
const mentionsGemini = lc.includes('gemini');
|
|
20
|
+
if (mentionsGemini && (lc.includes('not found') || lc.includes('enoent') || lc.includes('spawn'))) {
|
|
21
|
+
return 'Gemini CLI was not found. Install it and set GEMINI_BIN (or fix PATH), then restart.';
|
|
22
|
+
}
|
|
23
|
+
if (mentionsGemini && (lc.includes('unauthorized') || lc.includes('authentication') || lc.includes('not logged in'))) {
|
|
24
|
+
return 'Gemini CLI authentication is missing or expired. Re-authenticate Gemini CLI and retry.';
|
|
25
|
+
}
|
|
26
|
+
if (lc.includes('unauthorized') || lc.includes('authentication') || lc.includes('not logged in')) {
|
|
27
|
+
return 'Claude CLI authentication is missing or expired. Re-authenticate Claude CLI and retry.';
|
|
28
|
+
}
|
|
29
|
+
if (lc.includes('stream stall')) {
|
|
30
|
+
return 'The runtime stream stalled (no output received). This may indicate a network issue or API hang. Try again or increase DISCOCLAW_STREAM_STALL_TIMEOUT_MS.';
|
|
31
|
+
}
|
|
32
|
+
if (lc.includes('configuration error: missing required channel context')) {
|
|
33
|
+
return ('This channel is missing required context. Create/index the channel context file under content/discord/channels ' +
|
|
34
|
+
'or disable DISCORD_REQUIRE_CHANNEL_CONTEXT.');
|
|
35
|
+
}
|
|
36
|
+
if (!msg) {
|
|
37
|
+
return 'An unexpected runtime error occurred with no additional detail.';
|
|
38
|
+
}
|
|
39
|
+
return `Runtime error: ${msg}`;
|
|
40
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { mapRuntimeErrorToUserMessage } from './user-errors.js';
|
|
3
|
+
describe('mapRuntimeErrorToUserMessage', () => {
|
|
4
|
+
it('maps Claude binary missing errors to install guidance', () => {
|
|
5
|
+
const msg = mapRuntimeErrorToUserMessage('spawn claude ENOENT');
|
|
6
|
+
expect(msg).toContain('Claude CLI was not found');
|
|
7
|
+
});
|
|
8
|
+
it('does not misclassify generic ENOENT errors as Claude CLI missing', () => {
|
|
9
|
+
const msg = mapRuntimeErrorToUserMessage('ENOENT: no such file or directory, open /tmp/missing.json');
|
|
10
|
+
expect(msg).toContain('Runtime error:');
|
|
11
|
+
expect(msg).not.toContain('Claude CLI was not found');
|
|
12
|
+
});
|
|
13
|
+
it('maps Gemini binary missing errors to Gemini install guidance', () => {
|
|
14
|
+
const msg = mapRuntimeErrorToUserMessage('spawn gemini ENOENT');
|
|
15
|
+
expect(msg).toContain('Gemini CLI was not found');
|
|
16
|
+
});
|
|
17
|
+
it('maps Gemini auth errors to Gemini-specific guidance', () => {
|
|
18
|
+
const msg = mapRuntimeErrorToUserMessage('gemini: authentication failed');
|
|
19
|
+
expect(msg).toContain('Gemini CLI authentication');
|
|
20
|
+
expect(msg).not.toContain('Claude CLI');
|
|
21
|
+
});
|
|
22
|
+
it('does not misclassify generic auth errors as Gemini when gemini is not mentioned', () => {
|
|
23
|
+
const msg = mapRuntimeErrorToUserMessage('unauthorized');
|
|
24
|
+
expect(msg).toContain('Claude CLI authentication');
|
|
25
|
+
});
|
|
26
|
+
it('maps stream stall errors to stall-specific user message', () => {
|
|
27
|
+
const msg = mapRuntimeErrorToUserMessage('stream stall: no output for 120000ms');
|
|
28
|
+
expect(msg).toContain('stream stalled');
|
|
29
|
+
expect(msg).toContain('DISCOCLAW_STREAM_STALL_TIMEOUT_MS');
|
|
30
|
+
});
|
|
31
|
+
});
|