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,143 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import { writeShutdownContext } from './shutdown-context.js';
|
|
4
|
+
export function parseRestartCommand(content) {
|
|
5
|
+
const normalized = content.trim().toLowerCase().replace(/\s+/g, ' ');
|
|
6
|
+
if (normalized === '!restart')
|
|
7
|
+
return { action: 'restart' };
|
|
8
|
+
if (normalized === '!restart status')
|
|
9
|
+
return { action: 'status' };
|
|
10
|
+
if (normalized === '!restart logs')
|
|
11
|
+
return { action: 'logs' };
|
|
12
|
+
if (normalized === '!restart help')
|
|
13
|
+
return { action: 'help' };
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
function run(cmd, args) {
|
|
17
|
+
return new Promise((resolve) => {
|
|
18
|
+
execFile(cmd, args, { timeout: 15_000 }, (err, stdout, stderr) => {
|
|
19
|
+
const exitCode = err ? err.code ?? null : 0;
|
|
20
|
+
resolve({
|
|
21
|
+
stdout: String(stdout ?? ''),
|
|
22
|
+
stderr: String(stderr ?? ''),
|
|
23
|
+
exitCode: typeof exitCode === 'number' ? exitCode : null,
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
function getPlatformCommands() {
|
|
29
|
+
if (process.platform === 'linux') {
|
|
30
|
+
return {
|
|
31
|
+
statusCmd: ['systemctl', ['--user', 'status', 'discoclaw']],
|
|
32
|
+
logsCmd: ['journalctl', ['--user', '-u', 'discoclaw', '--no-pager', '-n', '30']],
|
|
33
|
+
checkActiveCmd: ['systemctl', ['--user', 'status', 'discoclaw']],
|
|
34
|
+
isActive: (result) => result.stdout.includes('active (running)'),
|
|
35
|
+
restartCmd: () => ['systemctl', ['--user', 'restart', 'discoclaw']],
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
if (process.platform === 'darwin') {
|
|
39
|
+
const uid = process.getuid?.() ?? 501;
|
|
40
|
+
const plistPath = `${os.homedir()}/Library/LaunchAgents/com.discoclaw.agent.plist`;
|
|
41
|
+
const domain = `gui/${uid}`;
|
|
42
|
+
const label = 'com.discoclaw.agent';
|
|
43
|
+
return {
|
|
44
|
+
statusCmd: ['launchctl', ['list', label]],
|
|
45
|
+
logsCmd: ['log', ['show', '--predicate', 'process == "node"', '--last', '5m', '--style', 'compact']],
|
|
46
|
+
checkActiveCmd: ['launchctl', ['list', label]],
|
|
47
|
+
isActive: (result) => result.exitCode === 0,
|
|
48
|
+
restartCmd: (wasActive) => wasActive
|
|
49
|
+
? ['launchctl', ['kickstart', '-k', `${domain}/${label}`]]
|
|
50
|
+
: ['launchctl', ['bootstrap', domain, plistPath]],
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Returns [cmd, args] for restarting the discoclaw service on the current
|
|
57
|
+
* platform, assuming the service is already running. Falls back to systemctl
|
|
58
|
+
* on unsupported platforms.
|
|
59
|
+
*/
|
|
60
|
+
export function getRestartCmdArgs() {
|
|
61
|
+
const pc = getPlatformCommands();
|
|
62
|
+
if (pc)
|
|
63
|
+
return pc.restartCmd(true);
|
|
64
|
+
return ['systemctl', ['--user', 'restart', 'discoclaw']];
|
|
65
|
+
}
|
|
66
|
+
export async function handleRestartCommand(cmd, opts) {
|
|
67
|
+
// Support both legacy (log) and new (opts bag) signatures.
|
|
68
|
+
const resolved = opts && typeof opts === 'object' && 'info' in opts
|
|
69
|
+
? { log: opts }
|
|
70
|
+
: opts ?? {};
|
|
71
|
+
const { log, dataDir, userId, activeForge } = resolved;
|
|
72
|
+
try {
|
|
73
|
+
if (cmd.action === 'help') {
|
|
74
|
+
return {
|
|
75
|
+
reply: [
|
|
76
|
+
'**!restart commands:**',
|
|
77
|
+
'- `!restart` — restart the discoclaw service',
|
|
78
|
+
'- `!restart status` — show service status',
|
|
79
|
+
'- `!restart logs` — show recent logs (last 30 lines)',
|
|
80
|
+
'- `!restart help` — this message',
|
|
81
|
+
].join('\n'),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
const pc = getPlatformCommands();
|
|
85
|
+
if (!pc) {
|
|
86
|
+
return {
|
|
87
|
+
reply: `!restart is not supported on this platform (${process.platform}). Only Linux (systemd) and macOS (launchd) are supported.`,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
if (cmd.action === 'status') {
|
|
91
|
+
const result = await run(pc.statusCmd[0], pc.statusCmd[1]);
|
|
92
|
+
const output = (result.stdout || result.stderr).trim();
|
|
93
|
+
log?.info({ exitCode: result.exitCode }, 'restart-command:status');
|
|
94
|
+
return { reply: `\`\`\`\n${output.slice(0, 1800)}\n\`\`\`` };
|
|
95
|
+
}
|
|
96
|
+
if (cmd.action === 'logs') {
|
|
97
|
+
const result = await run(pc.logsCmd[0], pc.logsCmd[1]);
|
|
98
|
+
const output = (result.stdout || result.stderr).trim();
|
|
99
|
+
log?.info({}, 'restart-command:logs');
|
|
100
|
+
return { reply: `\`\`\`\n${output.slice(0, 1800)}\n\`\`\`` };
|
|
101
|
+
}
|
|
102
|
+
// action === 'restart'
|
|
103
|
+
// Check current status for context in the reply.
|
|
104
|
+
const before = await run(pc.checkActiveCmd[0], pc.checkActiveCmd[1]);
|
|
105
|
+
const wasActive = pc.isActive(before);
|
|
106
|
+
log?.info({ wasActive }, 'restart-command:restart');
|
|
107
|
+
// We can't restart inline — the restart kills this process before
|
|
108
|
+
// we can reply. Instead, return a deferred function that the caller
|
|
109
|
+
// invokes *after* sending the reply to Discord.
|
|
110
|
+
return {
|
|
111
|
+
reply: wasActive
|
|
112
|
+
? 'Restarting discoclaw... back in a moment.'
|
|
113
|
+
: 'Starting discoclaw...',
|
|
114
|
+
deferred: () => {
|
|
115
|
+
// Write shutdown context right before triggering restart so it
|
|
116
|
+
// doesn't linger if the deferred never fires or restart fails.
|
|
117
|
+
if (dataDir) {
|
|
118
|
+
const ctx = {
|
|
119
|
+
reason: 'restart-command',
|
|
120
|
+
message: 'User requested via !restart',
|
|
121
|
+
timestamp: new Date().toISOString(),
|
|
122
|
+
requestedBy: userId,
|
|
123
|
+
activeForge,
|
|
124
|
+
};
|
|
125
|
+
// Synchronous-ish: writeFile + rename, then exec restart.
|
|
126
|
+
writeShutdownContext(dataDir, ctx).catch((err) => {
|
|
127
|
+
log?.warn({ err }, 'restart-command:failed to write shutdown context');
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
// Fire and forget — the process will die during this call.
|
|
131
|
+
const [restartBin, restartArgs] = pc.restartCmd(wasActive);
|
|
132
|
+
execFile(restartBin, restartArgs, (err) => {
|
|
133
|
+
// If we somehow survive (e.g., the service unit changed), log it.
|
|
134
|
+
if (err)
|
|
135
|
+
log?.error({ err }, 'restart-command:restart failed');
|
|
136
|
+
});
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
catch (err) {
|
|
141
|
+
return { reply: `Restart command error: ${String(err)}` };
|
|
142
|
+
}
|
|
143
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { parseRestartCommand, handleRestartCommand } from './restart-command.js';
|
|
3
|
+
import * as shutdownCtx from './shutdown-context.js';
|
|
4
|
+
vi.mock('node:child_process', () => ({
|
|
5
|
+
execFile: vi.fn((cmd, args, optsOrCb, maybeCb) => {
|
|
6
|
+
const cb = typeof optsOrCb === 'function' ? optsOrCb : maybeCb;
|
|
7
|
+
// Simulate systemctl status returning "active (running)"
|
|
8
|
+
if (args.includes('status')) {
|
|
9
|
+
cb(null, 'active (running)\n', '');
|
|
10
|
+
}
|
|
11
|
+
else if (args.includes('restart')) {
|
|
12
|
+
cb(null, '', '');
|
|
13
|
+
}
|
|
14
|
+
else {
|
|
15
|
+
// journalctl logs / launchctl list / log show / kickstart / bootstrap
|
|
16
|
+
cb(null, 'Feb 12 14:00:00 discoclaw[1234]: started\n', '');
|
|
17
|
+
}
|
|
18
|
+
}),
|
|
19
|
+
}));
|
|
20
|
+
vi.mock('node:os', () => ({
|
|
21
|
+
default: {
|
|
22
|
+
homedir: () => '/Users/testuser',
|
|
23
|
+
},
|
|
24
|
+
}));
|
|
25
|
+
const savedPlatform = process.platform;
|
|
26
|
+
describe('parseRestartCommand', () => {
|
|
27
|
+
it('parses !restart as restart action', () => {
|
|
28
|
+
expect(parseRestartCommand('!restart')).toEqual({ action: 'restart' });
|
|
29
|
+
});
|
|
30
|
+
it('parses !restart status', () => {
|
|
31
|
+
expect(parseRestartCommand('!restart status')).toEqual({ action: 'status' });
|
|
32
|
+
});
|
|
33
|
+
it('parses !restart logs', () => {
|
|
34
|
+
expect(parseRestartCommand('!restart logs')).toEqual({ action: 'logs' });
|
|
35
|
+
});
|
|
36
|
+
it('parses !restart help', () => {
|
|
37
|
+
expect(parseRestartCommand('!restart help')).toEqual({ action: 'help' });
|
|
38
|
+
});
|
|
39
|
+
it('returns null for non-restart messages', () => {
|
|
40
|
+
expect(parseRestartCommand('hello')).toBeNull();
|
|
41
|
+
expect(parseRestartCommand('!memory show')).toBeNull();
|
|
42
|
+
expect(parseRestartCommand('!restarting')).toBeNull();
|
|
43
|
+
});
|
|
44
|
+
it('is case-insensitive', () => {
|
|
45
|
+
expect(parseRestartCommand('!RESTART')).toEqual({ action: 'restart' });
|
|
46
|
+
expect(parseRestartCommand('!Restart Status')).toEqual({ action: 'status' });
|
|
47
|
+
});
|
|
48
|
+
it('handles whitespace', () => {
|
|
49
|
+
expect(parseRestartCommand(' !restart ')).toEqual({ action: 'restart' });
|
|
50
|
+
expect(parseRestartCommand(' !restart status ')).toEqual({ action: 'status' });
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
describe('handleRestartCommand', () => {
|
|
54
|
+
beforeEach(() => {
|
|
55
|
+
vi.clearAllMocks();
|
|
56
|
+
Object.defineProperty(process, 'platform', { value: 'linux', configurable: true });
|
|
57
|
+
});
|
|
58
|
+
afterEach(() => {
|
|
59
|
+
Object.defineProperty(process, 'platform', { value: savedPlatform, configurable: true });
|
|
60
|
+
});
|
|
61
|
+
it('help returns usage text without calling execFile', async () => {
|
|
62
|
+
const { execFile } = await import('node:child_process');
|
|
63
|
+
const result = await handleRestartCommand({ action: 'help' });
|
|
64
|
+
expect(result.reply).toContain('!restart commands');
|
|
65
|
+
expect(result.deferred).toBeUndefined();
|
|
66
|
+
expect(execFile).not.toHaveBeenCalled();
|
|
67
|
+
});
|
|
68
|
+
it('status returns code-block output', async () => {
|
|
69
|
+
const result = await handleRestartCommand({ action: 'status' });
|
|
70
|
+
expect(result.reply).toContain('```');
|
|
71
|
+
expect(result.reply).toContain('active (running)');
|
|
72
|
+
expect(result.deferred).toBeUndefined();
|
|
73
|
+
});
|
|
74
|
+
it('logs returns code-block output', async () => {
|
|
75
|
+
const result = await handleRestartCommand({ action: 'logs' });
|
|
76
|
+
expect(result.reply).toContain('```');
|
|
77
|
+
expect(result.reply).toContain('discoclaw');
|
|
78
|
+
expect(result.deferred).toBeUndefined();
|
|
79
|
+
});
|
|
80
|
+
it('restart returns a deferred function and correct reply', async () => {
|
|
81
|
+
const result = await handleRestartCommand({ action: 'restart' });
|
|
82
|
+
expect(result.reply).toBe('Restarting discoclaw... back in a moment.');
|
|
83
|
+
expect(typeof result.deferred).toBe('function');
|
|
84
|
+
});
|
|
85
|
+
it('does not write shutdown context until deferred is called', async () => {
|
|
86
|
+
const spy = vi.spyOn(shutdownCtx, 'writeShutdownContext').mockResolvedValue();
|
|
87
|
+
const result = await handleRestartCommand({ action: 'restart' }, { dataDir: '/tmp/test', userId: '123', activeForge: 'plan-001' });
|
|
88
|
+
// Before deferred: no write.
|
|
89
|
+
expect(spy).not.toHaveBeenCalled();
|
|
90
|
+
// After deferred: write happens.
|
|
91
|
+
result.deferred();
|
|
92
|
+
expect(spy).toHaveBeenCalledOnce();
|
|
93
|
+
expect(spy.mock.calls[0][1]).toMatchObject({
|
|
94
|
+
reason: 'restart-command',
|
|
95
|
+
requestedBy: '123',
|
|
96
|
+
activeForge: 'plan-001',
|
|
97
|
+
});
|
|
98
|
+
spy.mockRestore();
|
|
99
|
+
});
|
|
100
|
+
it('restart reports "Starting" when service was not active', async () => {
|
|
101
|
+
const { execFile } = await import('node:child_process');
|
|
102
|
+
// Override mock to simulate inactive service (only for the status-check call)
|
|
103
|
+
execFile.mockImplementationOnce((cmd, args, opts, cb) => {
|
|
104
|
+
cb(null, 'inactive (dead)\n', '');
|
|
105
|
+
});
|
|
106
|
+
const result = await handleRestartCommand({ action: 'restart' });
|
|
107
|
+
expect(result.reply).toBe('Starting discoclaw...');
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
describe('handleRestartCommand - macOS', () => {
|
|
111
|
+
beforeEach(() => {
|
|
112
|
+
vi.clearAllMocks();
|
|
113
|
+
Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true });
|
|
114
|
+
vi.spyOn(process, 'getuid').mockReturnValue(501);
|
|
115
|
+
});
|
|
116
|
+
afterEach(() => {
|
|
117
|
+
Object.defineProperty(process, 'platform', { value: savedPlatform, configurable: true });
|
|
118
|
+
vi.restoreAllMocks();
|
|
119
|
+
});
|
|
120
|
+
it('status calls launchctl list instead of systemctl', async () => {
|
|
121
|
+
const { execFile } = await import('node:child_process');
|
|
122
|
+
const result = await handleRestartCommand({ action: 'status' });
|
|
123
|
+
expect(result.reply).toContain('```');
|
|
124
|
+
expect(result.deferred).toBeUndefined();
|
|
125
|
+
const calls = execFile.mock.calls;
|
|
126
|
+
const statusCall = calls.find((c) => c[0] === 'launchctl');
|
|
127
|
+
expect(statusCall).toBeDefined();
|
|
128
|
+
expect(statusCall[1]).toContain('list');
|
|
129
|
+
expect(statusCall[1]).toContain('com.discoclaw.agent');
|
|
130
|
+
// Must NOT call systemctl
|
|
131
|
+
expect(calls.every((c) => c[0] !== 'systemctl')).toBe(true);
|
|
132
|
+
});
|
|
133
|
+
it('logs calls log show instead of journalctl', async () => {
|
|
134
|
+
const { execFile } = await import('node:child_process');
|
|
135
|
+
const result = await handleRestartCommand({ action: 'logs' });
|
|
136
|
+
expect(result.reply).toContain('```');
|
|
137
|
+
expect(result.deferred).toBeUndefined();
|
|
138
|
+
const calls = execFile.mock.calls;
|
|
139
|
+
const logsCall = calls.find((c) => c[0] === 'log');
|
|
140
|
+
expect(logsCall).toBeDefined();
|
|
141
|
+
expect(logsCall[1]).toContain('show');
|
|
142
|
+
expect(logsCall[1]).toContain('--predicate');
|
|
143
|
+
// Must NOT call journalctl
|
|
144
|
+
expect(calls.every((c) => c[0] !== 'journalctl')).toBe(true);
|
|
145
|
+
});
|
|
146
|
+
it('restart uses launchctl kickstart in deferred when service is active', async () => {
|
|
147
|
+
const { execFile } = await import('node:child_process');
|
|
148
|
+
// Default mock returns exit code 0 for launchctl list → wasActive = true
|
|
149
|
+
const result = await handleRestartCommand({ action: 'restart' });
|
|
150
|
+
expect(result.reply).toBe('Restarting discoclaw... back in a moment.');
|
|
151
|
+
expect(typeof result.deferred).toBe('function');
|
|
152
|
+
result.deferred();
|
|
153
|
+
const calls = execFile.mock.calls;
|
|
154
|
+
const kickstartCall = calls.find((c) => c[0] === 'launchctl' && c[1].includes('kickstart'));
|
|
155
|
+
expect(kickstartCall).toBeDefined();
|
|
156
|
+
expect(kickstartCall[1]).toContain('-k');
|
|
157
|
+
expect(kickstartCall[1]).toContain('gui/501/com.discoclaw.agent');
|
|
158
|
+
});
|
|
159
|
+
it('restart uses launchctl bootstrap in deferred when service is inactive', async () => {
|
|
160
|
+
const { execFile } = await import('node:child_process');
|
|
161
|
+
// Return non-zero exit code for the launchctl list check → wasActive = false
|
|
162
|
+
execFile.mockImplementationOnce((cmd, args, opts, cb) => {
|
|
163
|
+
const err = Object.assign(new Error('not loaded'), { code: 1 });
|
|
164
|
+
cb(err, '', '');
|
|
165
|
+
});
|
|
166
|
+
const result = await handleRestartCommand({ action: 'restart' });
|
|
167
|
+
expect(result.reply).toBe('Starting discoclaw...');
|
|
168
|
+
expect(typeof result.deferred).toBe('function');
|
|
169
|
+
result.deferred();
|
|
170
|
+
const calls = execFile.mock.calls;
|
|
171
|
+
const bootstrapCall = calls.find((c) => c[0] === 'launchctl' && c[1].includes('bootstrap'));
|
|
172
|
+
expect(bootstrapCall).toBeDefined();
|
|
173
|
+
expect(bootstrapCall[1]).toContain('gui/501');
|
|
174
|
+
expect(bootstrapCall[1]).toContain('/Users/testuser/Library/LaunchAgents/com.discoclaw.agent.plist');
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
describe('handleRestartCommand - unsupported platform', () => {
|
|
178
|
+
beforeEach(() => {
|
|
179
|
+
vi.clearAllMocks();
|
|
180
|
+
Object.defineProperty(process, 'platform', { value: 'win32', configurable: true });
|
|
181
|
+
});
|
|
182
|
+
afterEach(() => {
|
|
183
|
+
Object.defineProperty(process, 'platform', { value: savedPlatform, configurable: true });
|
|
184
|
+
});
|
|
185
|
+
it('restart returns explicit error message', async () => {
|
|
186
|
+
const result = await handleRestartCommand({ action: 'restart' });
|
|
187
|
+
expect(result.reply).toContain('!restart is not supported on this platform (win32)');
|
|
188
|
+
expect(result.reply).toContain('Only Linux (systemd) and macOS (launchd) are supported.');
|
|
189
|
+
expect(result.deferred).toBeUndefined();
|
|
190
|
+
});
|
|
191
|
+
it('status returns explicit error message', async () => {
|
|
192
|
+
const result = await handleRestartCommand({ action: 'status' });
|
|
193
|
+
expect(result.reply).toContain('!restart is not supported on this platform (win32)');
|
|
194
|
+
expect(result.reply).toContain('Only Linux (systemd) and macOS (launchd) are supported.');
|
|
195
|
+
});
|
|
196
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Collect the final text from a runtime invocation, streaming through all events.
|
|
3
|
+
*
|
|
4
|
+
* When `opts.requireFinalEvent` is true, throws if the stream ends without
|
|
5
|
+
* a `text_final` event (distinguishes a complete response from a truncated one).
|
|
6
|
+
*
|
|
7
|
+
* When `opts.onEvent` is provided, each event is forwarded to it before
|
|
8
|
+
* processing — used to drive live streaming preview in Discord progress messages.
|
|
9
|
+
*/
|
|
10
|
+
export async function collectRuntimeText(runtime, prompt, model, cwd, tools, addDirs, timeoutMs, opts) {
|
|
11
|
+
let text = '';
|
|
12
|
+
let sawFinal = false;
|
|
13
|
+
for await (const evt of runtime.invoke({
|
|
14
|
+
prompt,
|
|
15
|
+
model,
|
|
16
|
+
cwd,
|
|
17
|
+
tools,
|
|
18
|
+
addDirs: addDirs.length > 0 ? addDirs : undefined,
|
|
19
|
+
timeoutMs,
|
|
20
|
+
...(opts?.sessionKey ? { sessionKey: opts.sessionKey } : {}),
|
|
21
|
+
...(opts?.signal ? { signal: opts.signal } : {}),
|
|
22
|
+
})) {
|
|
23
|
+
try {
|
|
24
|
+
opts?.onEvent?.(evt);
|
|
25
|
+
}
|
|
26
|
+
catch { /* UI callback errors must not abort execution */ }
|
|
27
|
+
if (evt.type === 'text_final') {
|
|
28
|
+
text = evt.text;
|
|
29
|
+
sawFinal = true;
|
|
30
|
+
}
|
|
31
|
+
else if (evt.type === 'text_delta') {
|
|
32
|
+
// Accumulate deltas in case text_final isn't emitted
|
|
33
|
+
text += evt.text;
|
|
34
|
+
}
|
|
35
|
+
else if (evt.type === 'error') {
|
|
36
|
+
throw new Error(`Runtime error: ${evt.message}`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
if (opts?.requireFinalEvent && !sawFinal) {
|
|
40
|
+
throw new Error('Runtime stream ended without text_final event (response may be truncated)');
|
|
41
|
+
}
|
|
42
|
+
return text;
|
|
43
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { collectRuntimeText } from './runtime-utils.js';
|
|
3
|
+
function makeCaptureRuntime() {
|
|
4
|
+
const calls = [];
|
|
5
|
+
const runtime = {
|
|
6
|
+
id: 'claude_code',
|
|
7
|
+
capabilities: new Set(['streaming_text']),
|
|
8
|
+
invoke(params) {
|
|
9
|
+
calls.push(params);
|
|
10
|
+
return (async function* () {
|
|
11
|
+
yield { type: 'text_final', text: 'ok' };
|
|
12
|
+
})();
|
|
13
|
+
},
|
|
14
|
+
};
|
|
15
|
+
return { runtime, calls };
|
|
16
|
+
}
|
|
17
|
+
function makeMultiEventRuntime(events) {
|
|
18
|
+
return {
|
|
19
|
+
id: 'claude_code',
|
|
20
|
+
capabilities: new Set(['streaming_text']),
|
|
21
|
+
invoke() {
|
|
22
|
+
return (async function* () {
|
|
23
|
+
for (const evt of events)
|
|
24
|
+
yield evt;
|
|
25
|
+
})();
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
describe('collectRuntimeText', () => {
|
|
30
|
+
it('passes sessionKey through to runtime.invoke() when provided', async () => {
|
|
31
|
+
const { runtime, calls } = makeCaptureRuntime();
|
|
32
|
+
await collectRuntimeText(runtime, 'hello', 'test-model', '/tmp', ['Read'], [], 30000, { sessionKey: 'forge:plan-001:opus:drafter' });
|
|
33
|
+
expect(calls).toHaveLength(1);
|
|
34
|
+
expect(calls[0].sessionKey).toBe('forge:plan-001:opus:drafter');
|
|
35
|
+
});
|
|
36
|
+
it('does not include sessionKey in invoke params when opts omitted', async () => {
|
|
37
|
+
const { runtime, calls } = makeCaptureRuntime();
|
|
38
|
+
await collectRuntimeText(runtime, 'hello', 'test-model', '/tmp', ['Read'], [], 30000);
|
|
39
|
+
expect(calls).toHaveLength(1);
|
|
40
|
+
expect(calls[0].sessionKey).toBeUndefined();
|
|
41
|
+
});
|
|
42
|
+
it('does not include sessionKey when opts has no sessionKey', async () => {
|
|
43
|
+
const { runtime, calls } = makeCaptureRuntime();
|
|
44
|
+
await collectRuntimeText(runtime, 'hello', 'test-model', '/tmp', ['Read'], [], 30000, { requireFinalEvent: true });
|
|
45
|
+
expect(calls).toHaveLength(1);
|
|
46
|
+
expect(calls[0].sessionKey).toBeUndefined();
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
describe('collectRuntimeText signal', () => {
|
|
50
|
+
it('passes signal through to runtime.invoke() when provided', async () => {
|
|
51
|
+
const { runtime, calls } = makeCaptureRuntime();
|
|
52
|
+
const ac = new AbortController();
|
|
53
|
+
await collectRuntimeText(runtime, 'hello', 'test-model', '/tmp', ['Read'], [], 30000, { signal: ac.signal });
|
|
54
|
+
expect(calls).toHaveLength(1);
|
|
55
|
+
expect(calls[0].signal).toBe(ac.signal);
|
|
56
|
+
});
|
|
57
|
+
it('does not include signal when opts has no signal', async () => {
|
|
58
|
+
const { runtime, calls } = makeCaptureRuntime();
|
|
59
|
+
await collectRuntimeText(runtime, 'hello', 'test-model', '/tmp', ['Read'], [], 30000, { requireFinalEvent: true });
|
|
60
|
+
expect(calls).toHaveLength(1);
|
|
61
|
+
expect(calls[0].signal).toBeUndefined();
|
|
62
|
+
});
|
|
63
|
+
it('throws when runtime emits error due to pre-aborted signal', async () => {
|
|
64
|
+
const ac = new AbortController();
|
|
65
|
+
ac.abort();
|
|
66
|
+
const runtime = makeMultiEventRuntime([
|
|
67
|
+
{ type: 'error', message: 'aborted' },
|
|
68
|
+
]);
|
|
69
|
+
await expect(collectRuntimeText(runtime, 'p', 'm', '/tmp', [], [], 30000, { signal: ac.signal })).rejects.toThrow('aborted');
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
describe('collectRuntimeText onEvent', () => {
|
|
73
|
+
it('forwards events to onEvent in order', async () => {
|
|
74
|
+
const events = [
|
|
75
|
+
{ type: 'text_delta', text: 'hello' },
|
|
76
|
+
{ type: 'text_delta', text: ' world' },
|
|
77
|
+
{ type: 'text_final', text: 'hello world' },
|
|
78
|
+
];
|
|
79
|
+
const runtime = makeMultiEventRuntime(events);
|
|
80
|
+
const received = [];
|
|
81
|
+
await collectRuntimeText(runtime, 'p', 'm', '/tmp', [], [], 30000, {
|
|
82
|
+
onEvent: (evt) => received.push(evt),
|
|
83
|
+
});
|
|
84
|
+
expect(received).toEqual(events);
|
|
85
|
+
});
|
|
86
|
+
it('return value is unchanged when onEvent is provided', async () => {
|
|
87
|
+
const runtime = makeMultiEventRuntime([{ type: 'text_final', text: 'result text' }]);
|
|
88
|
+
const result = await collectRuntimeText(runtime, 'p', 'm', '/tmp', [], [], 30000, {
|
|
89
|
+
onEvent: () => { },
|
|
90
|
+
});
|
|
91
|
+
expect(result).toBe('result text');
|
|
92
|
+
});
|
|
93
|
+
it('does not propagate a throwing onEvent', async () => {
|
|
94
|
+
const runtime = makeMultiEventRuntime([{ type: 'text_final', text: 'ok' }]);
|
|
95
|
+
await expect(collectRuntimeText(runtime, 'p', 'm', '/tmp', [], [], 30000, {
|
|
96
|
+
onEvent: () => { throw new Error('callback error'); },
|
|
97
|
+
})).resolves.toBe('ok');
|
|
98
|
+
});
|
|
99
|
+
it('still processes all events even when onEvent throws', async () => {
|
|
100
|
+
const events = [
|
|
101
|
+
{ type: 'text_delta', text: 'a' },
|
|
102
|
+
{ type: 'text_final', text: 'final' },
|
|
103
|
+
];
|
|
104
|
+
const runtime = makeMultiEventRuntime(events);
|
|
105
|
+
const callCount = { n: 0 };
|
|
106
|
+
const result = await collectRuntimeText(runtime, 'p', 'm', '/tmp', [], [], 30000, {
|
|
107
|
+
onEvent: () => { callCount.n++; throw new Error('oops'); },
|
|
108
|
+
});
|
|
109
|
+
expect(callCount.n).toBe(2);
|
|
110
|
+
expect(result).toBe('final');
|
|
111
|
+
});
|
|
112
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { discordSessionKey } from './session-key.js';
|
|
3
|
+
describe('discordSessionKey', () => {
|
|
4
|
+
it('uses dm:<authorId> for DMs', () => {
|
|
5
|
+
expect(discordSessionKey({ channelId: 'c', authorId: 'u', isDm: true })).toBe('discord:dm:u');
|
|
6
|
+
});
|
|
7
|
+
it('uses thread:<threadId> for threads', () => {
|
|
8
|
+
expect(discordSessionKey({ channelId: 'c', authorId: 'u', isDm: false, threadId: 't' })).toBe('discord:thread:t');
|
|
9
|
+
});
|
|
10
|
+
it('uses channel:<channelId> for normal channels', () => {
|
|
11
|
+
expect(discordSessionKey({ channelId: 'c', authorId: 'u', isDm: false })).toBe('discord:channel:c');
|
|
12
|
+
});
|
|
13
|
+
});
|