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,80 @@
|
|
|
1
|
+
class Semaphore {
|
|
2
|
+
max;
|
|
3
|
+
active = 0;
|
|
4
|
+
queue = [];
|
|
5
|
+
constructor(max) {
|
|
6
|
+
this.max = max;
|
|
7
|
+
}
|
|
8
|
+
async acquire() {
|
|
9
|
+
if (!(this.max > 0)) {
|
|
10
|
+
return () => { };
|
|
11
|
+
}
|
|
12
|
+
if (this.active < this.max) {
|
|
13
|
+
this.active++;
|
|
14
|
+
let released = false;
|
|
15
|
+
return () => {
|
|
16
|
+
if (released)
|
|
17
|
+
return;
|
|
18
|
+
released = true;
|
|
19
|
+
this.release();
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
return await new Promise((resolve) => {
|
|
23
|
+
this.queue.push((release) => resolve(release));
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
release() {
|
|
27
|
+
const next = this.queue.shift();
|
|
28
|
+
if (next) {
|
|
29
|
+
// Keep active count constant: transfer slot to the next waiter.
|
|
30
|
+
let released = false;
|
|
31
|
+
next(() => {
|
|
32
|
+
if (released)
|
|
33
|
+
return;
|
|
34
|
+
released = true;
|
|
35
|
+
this.release();
|
|
36
|
+
});
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
this.active = Math.max(0, this.active - 1);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
export function createConcurrencyLimiter(maxConcurrentInvocations) {
|
|
43
|
+
const max = Number.isFinite(maxConcurrentInvocations)
|
|
44
|
+
? Math.max(0, Math.floor(maxConcurrentInvocations))
|
|
45
|
+
: 0;
|
|
46
|
+
if (max <= 0)
|
|
47
|
+
return null;
|
|
48
|
+
const sem = new Semaphore(max);
|
|
49
|
+
return {
|
|
50
|
+
max,
|
|
51
|
+
acquire: () => sem.acquire(),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Wrap a runtime adapter with a global concurrency limiter.
|
|
56
|
+
*
|
|
57
|
+
* Important: the permit is held for the entire lifetime of the async iterator,
|
|
58
|
+
* so consumers must exhaust/close the iterator to release the slot.
|
|
59
|
+
*/
|
|
60
|
+
export function withConcurrencyLimit(runtime, opts) {
|
|
61
|
+
const limiter = opts.limiter ?? createConcurrencyLimiter(opts.maxConcurrentInvocations);
|
|
62
|
+
if (!limiter)
|
|
63
|
+
return runtime;
|
|
64
|
+
return {
|
|
65
|
+
...runtime,
|
|
66
|
+
async *invoke(params) {
|
|
67
|
+
const release = await limiter.acquire();
|
|
68
|
+
opts.log?.debug?.({ max: limiter.max }, 'runtime:concurrency slot acquired');
|
|
69
|
+
try {
|
|
70
|
+
for await (const evt of runtime.invoke(params)) {
|
|
71
|
+
yield evt;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
finally {
|
|
75
|
+
release();
|
|
76
|
+
opts.log?.debug?.({ max: limiter.max }, 'runtime:concurrency slot released');
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { createConcurrencyLimiter, withConcurrencyLimit } from './concurrency-limit.js';
|
|
3
|
+
function makeDeferred() {
|
|
4
|
+
let resolve;
|
|
5
|
+
let reject;
|
|
6
|
+
const promise = new Promise((res, rej) => {
|
|
7
|
+
resolve = res;
|
|
8
|
+
reject = rej;
|
|
9
|
+
});
|
|
10
|
+
return { promise, resolve, reject };
|
|
11
|
+
}
|
|
12
|
+
describe('withConcurrencyLimit', () => {
|
|
13
|
+
it('serializes invocations when maxConcurrentInvocations=1', async () => {
|
|
14
|
+
const started = [];
|
|
15
|
+
const finishA = makeDeferred();
|
|
16
|
+
const finishB = makeDeferred();
|
|
17
|
+
const runtime = {
|
|
18
|
+
id: 'other',
|
|
19
|
+
capabilities: new Set(['streaming_text']),
|
|
20
|
+
async *invoke(params) {
|
|
21
|
+
started.push(params.prompt);
|
|
22
|
+
if (params.prompt === 'A')
|
|
23
|
+
await finishA.promise;
|
|
24
|
+
if (params.prompt === 'B')
|
|
25
|
+
await finishB.promise;
|
|
26
|
+
yield { type: 'text_final', text: params.prompt };
|
|
27
|
+
yield { type: 'done' };
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
const limited = withConcurrencyLimit(runtime, { maxConcurrentInvocations: 1 });
|
|
31
|
+
const consume = async (prompt) => {
|
|
32
|
+
const out = [];
|
|
33
|
+
for await (const evt of limited.invoke({ prompt, model: 'm', cwd: '/tmp' })) {
|
|
34
|
+
out.push(evt);
|
|
35
|
+
}
|
|
36
|
+
return out;
|
|
37
|
+
};
|
|
38
|
+
const pA = consume('A');
|
|
39
|
+
// Let A acquire the slot and start.
|
|
40
|
+
await vi.waitFor(() => {
|
|
41
|
+
expect(started).toEqual(['A']);
|
|
42
|
+
});
|
|
43
|
+
const pB = consume('B');
|
|
44
|
+
// B should not start until A finishes.
|
|
45
|
+
await new Promise((r) => setTimeout(r, 25));
|
|
46
|
+
expect(started).toEqual(['A']);
|
|
47
|
+
finishA.resolve();
|
|
48
|
+
await vi.waitFor(() => {
|
|
49
|
+
expect(started).toEqual(['A', 'B']);
|
|
50
|
+
});
|
|
51
|
+
finishB.resolve();
|
|
52
|
+
const [outA, outB] = await Promise.all([pA, pB]);
|
|
53
|
+
expect(outA.some((e) => e.type === 'text_final' && e.text === 'A')).toBe(true);
|
|
54
|
+
expect(outB.some((e) => e.type === 'text_final' && e.text === 'B')).toBe(true);
|
|
55
|
+
});
|
|
56
|
+
it('is a no-op when maxConcurrentInvocations=0', async () => {
|
|
57
|
+
const started = [];
|
|
58
|
+
const finishA = makeDeferred();
|
|
59
|
+
const finishB = makeDeferred();
|
|
60
|
+
const runtime = {
|
|
61
|
+
id: 'other',
|
|
62
|
+
capabilities: new Set(['streaming_text']),
|
|
63
|
+
async *invoke(params) {
|
|
64
|
+
started.push(params.prompt);
|
|
65
|
+
if (params.prompt === 'A')
|
|
66
|
+
await finishA.promise;
|
|
67
|
+
if (params.prompt === 'B')
|
|
68
|
+
await finishB.promise;
|
|
69
|
+
yield { type: 'done' };
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
const limited = withConcurrencyLimit(runtime, { maxConcurrentInvocations: 0 });
|
|
73
|
+
const pA = (async () => {
|
|
74
|
+
for await (const _ of limited.invoke({ prompt: 'A', model: 'm', cwd: '/tmp' })) {
|
|
75
|
+
// ignore
|
|
76
|
+
}
|
|
77
|
+
})();
|
|
78
|
+
const pB = (async () => {
|
|
79
|
+
for await (const _ of limited.invoke({ prompt: 'B', model: 'm', cwd: '/tmp' })) {
|
|
80
|
+
// ignore
|
|
81
|
+
}
|
|
82
|
+
})();
|
|
83
|
+
await vi.waitFor(() => {
|
|
84
|
+
expect(new Set(started)).toEqual(new Set(['A', 'B']));
|
|
85
|
+
});
|
|
86
|
+
finishA.resolve();
|
|
87
|
+
finishB.resolve();
|
|
88
|
+
await Promise.all([pA, pB]);
|
|
89
|
+
});
|
|
90
|
+
it('shares one limiter across multiple runtime wrappers', async () => {
|
|
91
|
+
const started = [];
|
|
92
|
+
const finishA = makeDeferred();
|
|
93
|
+
const finishB = makeDeferred();
|
|
94
|
+
const runtimeA = {
|
|
95
|
+
id: 'other',
|
|
96
|
+
capabilities: new Set(['streaming_text']),
|
|
97
|
+
async *invoke() {
|
|
98
|
+
started.push('A');
|
|
99
|
+
await finishA.promise;
|
|
100
|
+
yield { type: 'done' };
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
const runtimeB = {
|
|
104
|
+
id: 'other',
|
|
105
|
+
capabilities: new Set(['streaming_text']),
|
|
106
|
+
async *invoke() {
|
|
107
|
+
started.push('B');
|
|
108
|
+
await finishB.promise;
|
|
109
|
+
yield { type: 'done' };
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
const limiter = createConcurrencyLimiter(1);
|
|
113
|
+
const limitedA = withConcurrencyLimit(runtimeA, { maxConcurrentInvocations: 1, limiter });
|
|
114
|
+
const limitedB = withConcurrencyLimit(runtimeB, { maxConcurrentInvocations: 1, limiter });
|
|
115
|
+
const pA = (async () => {
|
|
116
|
+
for await (const _ of limitedA.invoke({ prompt: 'x', model: 'm', cwd: '/tmp' })) {
|
|
117
|
+
// ignore
|
|
118
|
+
}
|
|
119
|
+
})();
|
|
120
|
+
await vi.waitFor(() => {
|
|
121
|
+
expect(started).toEqual(['A']);
|
|
122
|
+
});
|
|
123
|
+
const pB = (async () => {
|
|
124
|
+
for await (const _ of limitedB.invoke({ prompt: 'x', model: 'm', cwd: '/tmp' })) {
|
|
125
|
+
// ignore
|
|
126
|
+
}
|
|
127
|
+
})();
|
|
128
|
+
await new Promise((r) => setTimeout(r, 25));
|
|
129
|
+
expect(started).toEqual(['A']);
|
|
130
|
+
finishA.resolve();
|
|
131
|
+
await vi.waitFor(() => {
|
|
132
|
+
expect(started).toEqual(['A', 'B']);
|
|
133
|
+
});
|
|
134
|
+
finishB.resolve();
|
|
135
|
+
await Promise.all([pA, pB]);
|
|
136
|
+
});
|
|
137
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Gemini CLI runtime adapter — thin wrapper around the universal CLI adapter.
|
|
2
|
+
// Phase 1: one-shot text mode. Sessions, JSONL streaming, and preflight are Phase 2.
|
|
3
|
+
// Auth is resolved by the `gemini` binary itself (OAuth via ~/.gemini/oauth_creds.json
|
|
4
|
+
// or GEMINI_API_KEY env var). This adapter spawns the binary and does not manage auth.
|
|
5
|
+
import { createCliRuntime, killAllSubprocesses } from './cli-adapter.js';
|
|
6
|
+
import { createGeminiStrategy } from './strategies/gemini-strategy.js';
|
|
7
|
+
/** SIGKILL all tracked Gemini subprocesses (e.g. on SIGTERM). */
|
|
8
|
+
export function killActiveGeminiSubprocesses() {
|
|
9
|
+
killAllSubprocesses();
|
|
10
|
+
}
|
|
11
|
+
export function createGeminiCliRuntime(opts) {
|
|
12
|
+
return createCliRuntime(createGeminiStrategy(opts.defaultModel), {
|
|
13
|
+
binary: opts.geminiBin,
|
|
14
|
+
log: opts.log,
|
|
15
|
+
});
|
|
16
|
+
}
|
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
// We mock execa at the module level so createGeminiCliRuntime uses our mock.
|
|
3
|
+
const mockExeca = vi.fn();
|
|
4
|
+
vi.mock('execa', () => ({
|
|
5
|
+
execa: (...args) => mockExeca(...args),
|
|
6
|
+
}));
|
|
7
|
+
// Import after mock setup.
|
|
8
|
+
const { createGeminiCliRuntime, killActiveGeminiSubprocesses } = await import('./gemini-cli.js');
|
|
9
|
+
async function collectEvents(iter) {
|
|
10
|
+
const events = [];
|
|
11
|
+
for await (const evt of iter) {
|
|
12
|
+
events.push(evt);
|
|
13
|
+
}
|
|
14
|
+
return events;
|
|
15
|
+
}
|
|
16
|
+
/** Create a mock subprocess that mimics execa's ResultPromise shape. */
|
|
17
|
+
function createMockSubprocess(opts) {
|
|
18
|
+
const stdoutChunks = opts.stdout ? [Buffer.from(opts.stdout)] : [];
|
|
19
|
+
const stdoutListeners = {};
|
|
20
|
+
const stderrListeners = {};
|
|
21
|
+
let thenCb = null;
|
|
22
|
+
let catchCb = null;
|
|
23
|
+
const mockStdout = {
|
|
24
|
+
on(event, cb) {
|
|
25
|
+
if (!stdoutListeners[event])
|
|
26
|
+
stdoutListeners[event] = [];
|
|
27
|
+
stdoutListeners[event].push(cb);
|
|
28
|
+
return mockStdout;
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
const mockStderr = {
|
|
32
|
+
on(event, cb) {
|
|
33
|
+
if (!stderrListeners[event])
|
|
34
|
+
stderrListeners[event] = [];
|
|
35
|
+
stderrListeners[event].push(cb);
|
|
36
|
+
return mockStderr;
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
const mockStdin = {
|
|
40
|
+
write: vi.fn(),
|
|
41
|
+
end: vi.fn(),
|
|
42
|
+
};
|
|
43
|
+
const subprocess = {
|
|
44
|
+
stdout: mockStdout,
|
|
45
|
+
stderr: mockStderr,
|
|
46
|
+
stdin: mockStdin,
|
|
47
|
+
pid: 12345,
|
|
48
|
+
kill: vi.fn(),
|
|
49
|
+
then(cb) {
|
|
50
|
+
thenCb = cb;
|
|
51
|
+
return { catch(cb2) { catchCb = cb2; } };
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
// Simulate async process completion.
|
|
55
|
+
// Use queueMicrotask to fire after the generator sets up listeners.
|
|
56
|
+
queueMicrotask(() => {
|
|
57
|
+
// Emit stdout data
|
|
58
|
+
for (const chunk of stdoutChunks) {
|
|
59
|
+
for (const cb of (stdoutListeners['data'] || [])) {
|
|
60
|
+
cb(chunk);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
// Emit stderr data
|
|
64
|
+
if (opts.stderr) {
|
|
65
|
+
for (const cb of (stderrListeners['data'] || [])) {
|
|
66
|
+
cb(Buffer.from(opts.stderr));
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
// End streams
|
|
70
|
+
for (const cb of (stdoutListeners['end'] || []))
|
|
71
|
+
cb();
|
|
72
|
+
for (const cb of (stderrListeners['end'] || []))
|
|
73
|
+
cb();
|
|
74
|
+
// Resolve/reject the process promise.
|
|
75
|
+
if (opts.rejectWith) {
|
|
76
|
+
catchCb?.(opts.rejectWith);
|
|
77
|
+
}
|
|
78
|
+
else if (opts.timedOut) {
|
|
79
|
+
catchCb?.({
|
|
80
|
+
timedOut: true,
|
|
81
|
+
message: 'timed out',
|
|
82
|
+
originalMessage: 'timed out',
|
|
83
|
+
shortMessage: 'timed out',
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
else {
|
|
87
|
+
const exitCode = opts.exitCode ?? 0;
|
|
88
|
+
const result = {
|
|
89
|
+
exitCode,
|
|
90
|
+
stdout: opts.stdout ?? '',
|
|
91
|
+
stderr: opts.stderr ?? '',
|
|
92
|
+
timedOut: false,
|
|
93
|
+
failed: exitCode !== 0 || (opts.failed ?? false),
|
|
94
|
+
...opts.resultExtra,
|
|
95
|
+
};
|
|
96
|
+
thenCb?.(result);
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
return subprocess;
|
|
100
|
+
}
|
|
101
|
+
describe('Gemini CLI runtime adapter', () => {
|
|
102
|
+
beforeEach(() => {
|
|
103
|
+
mockExeca.mockReset();
|
|
104
|
+
});
|
|
105
|
+
it('happy path: stdout text emits text_delta + text_final + done', async () => {
|
|
106
|
+
mockExeca.mockReturnValue(createMockSubprocess({
|
|
107
|
+
stdout: 'Hello world',
|
|
108
|
+
exitCode: 0,
|
|
109
|
+
}));
|
|
110
|
+
const rt = createGeminiCliRuntime({
|
|
111
|
+
geminiBin: 'gemini',
|
|
112
|
+
defaultModel: 'gemini-2.5-pro',
|
|
113
|
+
});
|
|
114
|
+
const events = await collectEvents(rt.invoke({
|
|
115
|
+
prompt: 'Say hello',
|
|
116
|
+
model: '',
|
|
117
|
+
cwd: '/tmp',
|
|
118
|
+
}));
|
|
119
|
+
const deltas = events.filter((e) => e.type === 'text_delta');
|
|
120
|
+
expect(deltas.length).toBeGreaterThan(0);
|
|
121
|
+
expect(deltas.map((d) => d.text).join('')).toBe('Hello world');
|
|
122
|
+
const final = events.find((e) => e.type === 'text_final');
|
|
123
|
+
expect(final).toBeDefined();
|
|
124
|
+
expect(final.text).toBe('Hello world');
|
|
125
|
+
expect(events[events.length - 1].type).toBe('done');
|
|
126
|
+
});
|
|
127
|
+
it('error path: non-zero exit code emits error + done', async () => {
|
|
128
|
+
mockExeca.mockReturnValue(createMockSubprocess({
|
|
129
|
+
stdout: '',
|
|
130
|
+
stderr: 'model not found',
|
|
131
|
+
exitCode: 1,
|
|
132
|
+
}));
|
|
133
|
+
const rt = createGeminiCliRuntime({
|
|
134
|
+
geminiBin: 'gemini',
|
|
135
|
+
defaultModel: 'gemini-2.5-pro',
|
|
136
|
+
});
|
|
137
|
+
const events = await collectEvents(rt.invoke({
|
|
138
|
+
prompt: 'Say hello',
|
|
139
|
+
model: '',
|
|
140
|
+
cwd: '/tmp',
|
|
141
|
+
}));
|
|
142
|
+
const errorEvt = events.find((e) => e.type === 'error');
|
|
143
|
+
expect(errorEvt).toBeDefined();
|
|
144
|
+
expect(errorEvt.message).toContain('model not found');
|
|
145
|
+
expect(events[events.length - 1].type).toBe('done');
|
|
146
|
+
});
|
|
147
|
+
it('timeout path: timedOut flag emits timeout error + done', async () => {
|
|
148
|
+
mockExeca.mockReturnValue(createMockSubprocess({
|
|
149
|
+
timedOut: true,
|
|
150
|
+
exitCode: undefined,
|
|
151
|
+
}));
|
|
152
|
+
const rt = createGeminiCliRuntime({
|
|
153
|
+
geminiBin: 'gemini',
|
|
154
|
+
defaultModel: 'gemini-2.5-pro',
|
|
155
|
+
});
|
|
156
|
+
const events = await collectEvents(rt.invoke({
|
|
157
|
+
prompt: 'Say hello',
|
|
158
|
+
model: '',
|
|
159
|
+
cwd: '/tmp',
|
|
160
|
+
timeoutMs: 5000,
|
|
161
|
+
}));
|
|
162
|
+
const errorEvt = events.find((e) => e.type === 'error');
|
|
163
|
+
expect(errorEvt).toBeDefined();
|
|
164
|
+
expect(errorEvt.message).toContain('timed out');
|
|
165
|
+
expect(events[events.length - 1].type).toBe('done');
|
|
166
|
+
});
|
|
167
|
+
it('model override: params.model takes precedence over defaultModel', async () => {
|
|
168
|
+
mockExeca.mockReturnValue(createMockSubprocess({
|
|
169
|
+
stdout: 'ok',
|
|
170
|
+
exitCode: 0,
|
|
171
|
+
}));
|
|
172
|
+
const rt = createGeminiCliRuntime({
|
|
173
|
+
geminiBin: 'gemini',
|
|
174
|
+
defaultModel: 'gemini-2.5-pro',
|
|
175
|
+
});
|
|
176
|
+
await collectEvents(rt.invoke({
|
|
177
|
+
prompt: 'Hi',
|
|
178
|
+
model: 'gemini-2.5-flash',
|
|
179
|
+
cwd: '/tmp',
|
|
180
|
+
}));
|
|
181
|
+
expect(mockExeca).toHaveBeenCalledTimes(1);
|
|
182
|
+
const callArgs = mockExeca.mock.calls[0][1];
|
|
183
|
+
const modelIdx = callArgs.indexOf('--model');
|
|
184
|
+
expect(callArgs[modelIdx + 1]).toBe('gemini-2.5-flash');
|
|
185
|
+
});
|
|
186
|
+
it('inserts -- argument terminator before prompt to prevent flag parsing', async () => {
|
|
187
|
+
mockExeca.mockReturnValue(createMockSubprocess({
|
|
188
|
+
stdout: 'ok',
|
|
189
|
+
exitCode: 0,
|
|
190
|
+
}));
|
|
191
|
+
const rt = createGeminiCliRuntime({
|
|
192
|
+
geminiBin: 'gemini',
|
|
193
|
+
defaultModel: 'gemini-2.5-pro',
|
|
194
|
+
});
|
|
195
|
+
await collectEvents(rt.invoke({
|
|
196
|
+
prompt: '--- SOUL.md ---\ntext',
|
|
197
|
+
model: '',
|
|
198
|
+
cwd: '/tmp',
|
|
199
|
+
}));
|
|
200
|
+
const callArgs = mockExeca.mock.calls[0][1];
|
|
201
|
+
const dashdashIdx = callArgs.indexOf('--');
|
|
202
|
+
expect(dashdashIdx).toBeGreaterThan(-1);
|
|
203
|
+
expect(callArgs[dashdashIdx + 1]).toBe('--- SOUL.md ---\ntext');
|
|
204
|
+
});
|
|
205
|
+
it('empty model fallback: params.model="" resolves to defaultModel', async () => {
|
|
206
|
+
mockExeca.mockReturnValue(createMockSubprocess({
|
|
207
|
+
stdout: 'ok',
|
|
208
|
+
exitCode: 0,
|
|
209
|
+
}));
|
|
210
|
+
const rt = createGeminiCliRuntime({
|
|
211
|
+
geminiBin: 'gemini',
|
|
212
|
+
defaultModel: 'gemini-2.5-pro',
|
|
213
|
+
});
|
|
214
|
+
await collectEvents(rt.invoke({
|
|
215
|
+
prompt: 'Hi',
|
|
216
|
+
model: '',
|
|
217
|
+
cwd: '/tmp',
|
|
218
|
+
}));
|
|
219
|
+
expect(mockExeca).toHaveBeenCalledTimes(1);
|
|
220
|
+
const callArgs = mockExeca.mock.calls[0][1];
|
|
221
|
+
const modelIdx = callArgs.indexOf('--model');
|
|
222
|
+
expect(callArgs[modelIdx + 1]).toBe('gemini-2.5-pro');
|
|
223
|
+
});
|
|
224
|
+
it('large prompt uses stdin instead of positional arg', async () => {
|
|
225
|
+
const largePrompt = 'x'.repeat(200_000);
|
|
226
|
+
mockExeca.mockReturnValue(createMockSubprocess({
|
|
227
|
+
stdout: 'ok',
|
|
228
|
+
exitCode: 0,
|
|
229
|
+
}));
|
|
230
|
+
const rt = createGeminiCliRuntime({
|
|
231
|
+
geminiBin: 'gemini',
|
|
232
|
+
defaultModel: 'gemini-2.5-pro',
|
|
233
|
+
});
|
|
234
|
+
await collectEvents(rt.invoke({
|
|
235
|
+
prompt: largePrompt,
|
|
236
|
+
model: '',
|
|
237
|
+
cwd: '/tmp',
|
|
238
|
+
}));
|
|
239
|
+
expect(mockExeca).toHaveBeenCalledTimes(1);
|
|
240
|
+
const callArgs = mockExeca.mock.calls[0][1];
|
|
241
|
+
// Should NOT contain the large prompt as a positional arg.
|
|
242
|
+
expect(callArgs).not.toContain(largePrompt);
|
|
243
|
+
// No -- terminator when using stdin (no positional arg).
|
|
244
|
+
expect(callArgs).not.toContain('--');
|
|
245
|
+
// Verify stdin was used.
|
|
246
|
+
const execaOpts = mockExeca.mock.calls[0][2];
|
|
247
|
+
expect(execaOpts.stdin).toBe('pipe');
|
|
248
|
+
});
|
|
249
|
+
it('runtime has correct id and capabilities', () => {
|
|
250
|
+
const rt = createGeminiCliRuntime({
|
|
251
|
+
geminiBin: 'gemini',
|
|
252
|
+
defaultModel: 'gemini-2.5-pro',
|
|
253
|
+
});
|
|
254
|
+
expect(rt.id).toBe('gemini');
|
|
255
|
+
expect(rt.capabilities.has('streaming_text')).toBe(true);
|
|
256
|
+
// Phase 1: no sessions, no fs tools, no exec tools declared.
|
|
257
|
+
expect(rt.capabilities.has('sessions')).toBe(false);
|
|
258
|
+
expect(rt.capabilities.has('tools_fs')).toBe(false);
|
|
259
|
+
expect(rt.capabilities.has('tools_exec')).toBe(false);
|
|
260
|
+
});
|
|
261
|
+
it('shutdown cleanup: killActiveGeminiSubprocesses kills tracked processes', async () => {
|
|
262
|
+
const sub = createMockSubprocess({
|
|
263
|
+
stdout: '',
|
|
264
|
+
exitCode: 0,
|
|
265
|
+
});
|
|
266
|
+
// Prevent the subprocess from resolving immediately so it stays tracked.
|
|
267
|
+
sub.then = () => ({ catch: () => { } });
|
|
268
|
+
mockExeca.mockReturnValue(sub);
|
|
269
|
+
const rt = createGeminiCliRuntime({
|
|
270
|
+
geminiBin: 'gemini',
|
|
271
|
+
defaultModel: 'gemini-2.5-pro',
|
|
272
|
+
});
|
|
273
|
+
// Start invoke but don't consume fully — just trigger subprocess creation.
|
|
274
|
+
const iter = rt.invoke({
|
|
275
|
+
prompt: 'Hi',
|
|
276
|
+
model: '',
|
|
277
|
+
cwd: '/tmp',
|
|
278
|
+
});
|
|
279
|
+
// Pull one event to start the generator (which adds the subprocess to tracking).
|
|
280
|
+
const iterResult = iter;
|
|
281
|
+
iterResult.next(); // don't await — we just need it to run up to the first yield/await
|
|
282
|
+
await new Promise(r => setTimeout(r, 10));
|
|
283
|
+
// Kill all tracked subprocesses.
|
|
284
|
+
killActiveGeminiSubprocesses();
|
|
285
|
+
expect(sub.kill).toHaveBeenCalledWith('SIGKILL');
|
|
286
|
+
});
|
|
287
|
+
it('empty stdout emits done without text_final', async () => {
|
|
288
|
+
mockExeca.mockReturnValue(createMockSubprocess({
|
|
289
|
+
stdout: '',
|
|
290
|
+
exitCode: 0,
|
|
291
|
+
}));
|
|
292
|
+
const rt = createGeminiCliRuntime({
|
|
293
|
+
geminiBin: 'gemini',
|
|
294
|
+
defaultModel: 'gemini-2.5-pro',
|
|
295
|
+
});
|
|
296
|
+
const events = await collectEvents(rt.invoke({
|
|
297
|
+
prompt: 'Hi',
|
|
298
|
+
model: '',
|
|
299
|
+
cwd: '/tmp',
|
|
300
|
+
}));
|
|
301
|
+
// No text_final for empty response.
|
|
302
|
+
expect(events.find((e) => e.type === 'text_final')).toBeUndefined();
|
|
303
|
+
expect(events[events.length - 1].type).toBe('done');
|
|
304
|
+
});
|
|
305
|
+
it('error messages are sanitized: multi-line stderr returns first line only', async () => {
|
|
306
|
+
mockExeca.mockReturnValue(createMockSubprocess({
|
|
307
|
+
stdout: '',
|
|
308
|
+
stderr: 'auth token expired\nfull prompt: You are a helpful assistant...\nsession: /tmp/gemini/abc123',
|
|
309
|
+
exitCode: 1,
|
|
310
|
+
}));
|
|
311
|
+
const rt = createGeminiCliRuntime({
|
|
312
|
+
geminiBin: 'gemini',
|
|
313
|
+
defaultModel: 'gemini-2.5-pro',
|
|
314
|
+
});
|
|
315
|
+
const events = await collectEvents(rt.invoke({
|
|
316
|
+
prompt: 'Say hello',
|
|
317
|
+
model: '',
|
|
318
|
+
cwd: '/tmp',
|
|
319
|
+
}));
|
|
320
|
+
const errorEvt = events.find((e) => e.type === 'error');
|
|
321
|
+
expect(errorEvt).toBeDefined();
|
|
322
|
+
const msg = errorEvt.message;
|
|
323
|
+
// Should contain first line only.
|
|
324
|
+
expect(msg).toContain('auth token expired');
|
|
325
|
+
// Should NOT contain prompt or session content from subsequent lines.
|
|
326
|
+
expect(msg).not.toContain('full prompt');
|
|
327
|
+
expect(msg).not.toContain('session:');
|
|
328
|
+
});
|
|
329
|
+
it('ENOENT via tryFinalize: uses fixed message, never leaks prompt', async () => {
|
|
330
|
+
mockExeca.mockReturnValue(createMockSubprocess({
|
|
331
|
+
stdout: '',
|
|
332
|
+
exitCode: undefined,
|
|
333
|
+
failed: true,
|
|
334
|
+
resultExtra: {
|
|
335
|
+
exitCode: null,
|
|
336
|
+
failed: true,
|
|
337
|
+
code: 'ENOENT',
|
|
338
|
+
originalMessage: 'spawn gemini ENOENT',
|
|
339
|
+
shortMessage: "Command failed: gemini --model gemini-2.5-pro -- 'TOP SECRET PROMPT DATA'\nspawn gemini ENOENT",
|
|
340
|
+
},
|
|
341
|
+
}));
|
|
342
|
+
const rt = createGeminiCliRuntime({
|
|
343
|
+
geminiBin: 'gemini',
|
|
344
|
+
defaultModel: 'gemini-2.5-pro',
|
|
345
|
+
});
|
|
346
|
+
const events = await collectEvents(rt.invoke({
|
|
347
|
+
prompt: 'TOP SECRET PROMPT DATA',
|
|
348
|
+
model: '',
|
|
349
|
+
cwd: '/tmp',
|
|
350
|
+
}));
|
|
351
|
+
const errorEvt = events.find((e) => e.type === 'error');
|
|
352
|
+
expect(errorEvt).toBeDefined();
|
|
353
|
+
const msg = errorEvt.message;
|
|
354
|
+
expect(msg).toContain('gemini binary not found');
|
|
355
|
+
expect(msg).not.toContain('TOP SECRET');
|
|
356
|
+
expect(msg).not.toContain('Command failed');
|
|
357
|
+
expect(events[events.length - 1].type).toBe('done');
|
|
358
|
+
});
|
|
359
|
+
it('ENOENT via catch handler: uses fixed message, never leaks prompt', async () => {
|
|
360
|
+
mockExeca.mockReturnValue(createMockSubprocess({
|
|
361
|
+
stdout: '',
|
|
362
|
+
rejectWith: {
|
|
363
|
+
code: 'ENOENT',
|
|
364
|
+
originalMessage: 'spawn gemini ENOENT',
|
|
365
|
+
shortMessage: "Command failed: gemini --model gemini-2.5-pro -- 'TOP SECRET PROMPT DATA'\nspawn gemini ENOENT",
|
|
366
|
+
message: "Command failed: gemini --model gemini-2.5-pro -- 'TOP SECRET PROMPT DATA'\nspawn gemini ENOENT",
|
|
367
|
+
},
|
|
368
|
+
}));
|
|
369
|
+
const rt = createGeminiCliRuntime({
|
|
370
|
+
geminiBin: 'gemini',
|
|
371
|
+
defaultModel: 'gemini-2.5-pro',
|
|
372
|
+
});
|
|
373
|
+
const events = await collectEvents(rt.invoke({
|
|
374
|
+
prompt: 'TOP SECRET PROMPT DATA',
|
|
375
|
+
model: '',
|
|
376
|
+
cwd: '/tmp',
|
|
377
|
+
}));
|
|
378
|
+
const errorEvt = events.find((e) => e.type === 'error');
|
|
379
|
+
expect(errorEvt).toBeDefined();
|
|
380
|
+
const msg = errorEvt.message;
|
|
381
|
+
expect(msg).toContain('gemini binary not found');
|
|
382
|
+
expect(msg).not.toContain('TOP SECRET');
|
|
383
|
+
expect(msg).not.toContain('Command failed');
|
|
384
|
+
expect(events[events.length - 1].type).toBe('done');
|
|
385
|
+
});
|
|
386
|
+
it('non-ENOENT spawn failure via catch handler: generic message, no raw error', async () => {
|
|
387
|
+
mockExeca.mockReturnValue(createMockSubprocess({
|
|
388
|
+
stdout: '',
|
|
389
|
+
rejectWith: {
|
|
390
|
+
code: 'EACCES',
|
|
391
|
+
originalMessage: 'spawn gemini EACCES',
|
|
392
|
+
shortMessage: "Command failed: gemini --model gemini-2.5-pro -- 'secret prompt'\nspawn gemini EACCES",
|
|
393
|
+
message: "Command failed: gemini ...",
|
|
394
|
+
},
|
|
395
|
+
}));
|
|
396
|
+
const rt = createGeminiCliRuntime({
|
|
397
|
+
geminiBin: 'gemini',
|
|
398
|
+
defaultModel: 'gemini-2.5-pro',
|
|
399
|
+
});
|
|
400
|
+
const events = await collectEvents(rt.invoke({
|
|
401
|
+
prompt: 'secret prompt',
|
|
402
|
+
model: '',
|
|
403
|
+
cwd: '/tmp',
|
|
404
|
+
}));
|
|
405
|
+
const errorEvt = events.find((e) => e.type === 'error');
|
|
406
|
+
expect(errorEvt).toBeDefined();
|
|
407
|
+
const msg = errorEvt.message;
|
|
408
|
+
expect(msg).toBe('gemini process failed unexpectedly (EACCES)');
|
|
409
|
+
expect(msg).not.toContain('secret prompt');
|
|
410
|
+
expect(msg).not.toContain('Command failed');
|
|
411
|
+
expect(events[events.length - 1].type).toBe('done');
|
|
412
|
+
});
|
|
413
|
+
});
|