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,1199 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { Readable, PassThrough } from 'node:stream';
|
|
3
|
+
vi.mock('execa', () => ({
|
|
4
|
+
execa: vi.fn(),
|
|
5
|
+
}));
|
|
6
|
+
import { execa } from 'execa';
|
|
7
|
+
import { createClaudeCliRuntime, extractImageFromUnknownEvent, extractResultContentBlocks, imageDedupeKey, } from './claude-code-cli.js';
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
execa.mockReset?.();
|
|
10
|
+
});
|
|
11
|
+
function makeProcessText(args) {
|
|
12
|
+
const p = Promise.resolve({
|
|
13
|
+
stdout: args.stdout,
|
|
14
|
+
stderr: args.stderr ?? '',
|
|
15
|
+
exitCode: args.exitCode,
|
|
16
|
+
});
|
|
17
|
+
// Must be present or the adapter yields an error.
|
|
18
|
+
p.stdout = Readable.from([]);
|
|
19
|
+
p.stderr = Readable.from([]);
|
|
20
|
+
return p;
|
|
21
|
+
}
|
|
22
|
+
function makeProcessStreamJson(args) {
|
|
23
|
+
const p = Promise.resolve({ exitCode: args.exitCode });
|
|
24
|
+
p.stdout = Readable.from(args.lines.map((l) => l + '\n'));
|
|
25
|
+
p.stderr = Readable.from([]);
|
|
26
|
+
return p;
|
|
27
|
+
}
|
|
28
|
+
describe('Claude CLI runtime adapter (smoke)', () => {
|
|
29
|
+
it('text mode yields text_final', async () => {
|
|
30
|
+
const execaMock = execa;
|
|
31
|
+
execaMock.mockImplementation(() => makeProcessText({ stdout: 'hello', exitCode: 0 }));
|
|
32
|
+
const rt = createClaudeCliRuntime({
|
|
33
|
+
claudeBin: 'claude',
|
|
34
|
+
dangerouslySkipPermissions: false,
|
|
35
|
+
outputFormat: 'text',
|
|
36
|
+
});
|
|
37
|
+
const events = [];
|
|
38
|
+
for await (const evt of rt.invoke({
|
|
39
|
+
prompt: 'p',
|
|
40
|
+
model: 'opus',
|
|
41
|
+
cwd: '/tmp',
|
|
42
|
+
sessionId: 'sess',
|
|
43
|
+
tools: ['Read', 'Bash'],
|
|
44
|
+
addDirs: ['/w', '/c'],
|
|
45
|
+
timeoutMs: 1234,
|
|
46
|
+
})) {
|
|
47
|
+
events.push(evt);
|
|
48
|
+
}
|
|
49
|
+
expect(events.find((e) => e.type === 'text_final')?.text).toBe('hello');
|
|
50
|
+
const callArgs = execaMock.mock.calls[0]?.[1] ?? [];
|
|
51
|
+
expect(callArgs).toContain('--model');
|
|
52
|
+
expect(callArgs).toContain('opus');
|
|
53
|
+
expect(callArgs).toContain('--session-id');
|
|
54
|
+
expect(callArgs).toContain('sess');
|
|
55
|
+
expect(callArgs).toContain('--tools');
|
|
56
|
+
expect(callArgs).toContain('Read,Bash');
|
|
57
|
+
// --add-dir should be repeated per directory
|
|
58
|
+
const addDirIndices = callArgs
|
|
59
|
+
.map((v, i) => v === '--add-dir' ? i : -1)
|
|
60
|
+
.filter((i) => i >= 0);
|
|
61
|
+
expect(addDirIndices).toHaveLength(2);
|
|
62
|
+
expect(callArgs[addDirIndices[0] + 1]).toBe('/w');
|
|
63
|
+
expect(callArgs[addDirIndices[1] + 1]).toBe('/c');
|
|
64
|
+
// Prompt must follow `--` separator
|
|
65
|
+
const sepIdx = callArgs.indexOf('--');
|
|
66
|
+
expect(sepIdx).toBeGreaterThanOrEqual(0);
|
|
67
|
+
expect(callArgs[sepIdx + 1]).toBe('p');
|
|
68
|
+
});
|
|
69
|
+
it('stream-json mode yields merged text_final', async () => {
|
|
70
|
+
const execaMock = execa;
|
|
71
|
+
execaMock.mockImplementation(() => makeProcessStreamJson({
|
|
72
|
+
lines: [
|
|
73
|
+
JSON.stringify({ type: 'message_delta', text: 'Hello' }),
|
|
74
|
+
JSON.stringify({ type: 'message_delta', text: ' world' }),
|
|
75
|
+
],
|
|
76
|
+
exitCode: 0,
|
|
77
|
+
}));
|
|
78
|
+
const rt = createClaudeCliRuntime({
|
|
79
|
+
claudeBin: 'claude',
|
|
80
|
+
dangerouslySkipPermissions: true,
|
|
81
|
+
outputFormat: 'stream-json',
|
|
82
|
+
});
|
|
83
|
+
const events = [];
|
|
84
|
+
for await (const evt of rt.invoke({
|
|
85
|
+
prompt: 'p',
|
|
86
|
+
model: 'opus',
|
|
87
|
+
cwd: '/tmp',
|
|
88
|
+
})) {
|
|
89
|
+
events.push(evt);
|
|
90
|
+
}
|
|
91
|
+
expect(events.filter((e) => e.type === 'text_delta').map((e) => e.text).join('')).toBe('Hello world');
|
|
92
|
+
expect(events.find((e) => e.type === 'text_final')?.text).toBe('Hello world');
|
|
93
|
+
const callArgs = execaMock.mock.calls[0]?.[1] ?? [];
|
|
94
|
+
expect(callArgs).toContain('--output-format');
|
|
95
|
+
expect(callArgs).toContain('stream-json');
|
|
96
|
+
expect(callArgs).toContain('--dangerously-skip-permissions');
|
|
97
|
+
expect(callArgs).toContain('--include-partial-messages');
|
|
98
|
+
// Prompt must follow `--` separator
|
|
99
|
+
const sepIdx = callArgs.indexOf('--');
|
|
100
|
+
expect(sepIdx).toBeGreaterThanOrEqual(0);
|
|
101
|
+
expect(callArgs[sepIdx + 1]).toBe('p');
|
|
102
|
+
});
|
|
103
|
+
it('explicit empty tools uses --tools= syntax', async () => {
|
|
104
|
+
const execaMock = execa;
|
|
105
|
+
execaMock.mockImplementation(() => makeProcessText({ stdout: 'ok', exitCode: 0 }));
|
|
106
|
+
const rt = createClaudeCliRuntime({
|
|
107
|
+
claudeBin: 'claude',
|
|
108
|
+
dangerouslySkipPermissions: true,
|
|
109
|
+
outputFormat: 'text',
|
|
110
|
+
});
|
|
111
|
+
const events = [];
|
|
112
|
+
for await (const evt of rt.invoke({
|
|
113
|
+
prompt: 'p',
|
|
114
|
+
model: 'opus',
|
|
115
|
+
cwd: '/tmp',
|
|
116
|
+
tools: [],
|
|
117
|
+
})) {
|
|
118
|
+
events.push(evt);
|
|
119
|
+
}
|
|
120
|
+
expect(events.find((e) => e.type === 'text_final')?.text).toBe('ok');
|
|
121
|
+
const callArgs = execaMock.mock.calls[0]?.[1] ?? [];
|
|
122
|
+
// Should use `--tools=` (single element) not `--tools` + `''` (two elements)
|
|
123
|
+
expect(callArgs).toContain('--tools=');
|
|
124
|
+
expect(callArgs.filter((x) => x === '--tools')).toHaveLength(0);
|
|
125
|
+
// Prompt must follow `--` separator
|
|
126
|
+
const sepIdx = callArgs.indexOf('--');
|
|
127
|
+
expect(sepIdx).toBeGreaterThanOrEqual(0);
|
|
128
|
+
expect(callArgs[sepIdx + 1]).toBe('p');
|
|
129
|
+
});
|
|
130
|
+
it('--strict-mcp-config is passed when enabled', async () => {
|
|
131
|
+
const execaMock = execa;
|
|
132
|
+
execaMock.mockImplementation(() => makeProcessText({ stdout: 'ok', exitCode: 0 }));
|
|
133
|
+
const rt = createClaudeCliRuntime({
|
|
134
|
+
claudeBin: 'claude',
|
|
135
|
+
dangerouslySkipPermissions: false,
|
|
136
|
+
outputFormat: 'text',
|
|
137
|
+
strictMcpConfig: true,
|
|
138
|
+
});
|
|
139
|
+
for await (const _evt of rt.invoke({ prompt: 'p', model: 'opus', cwd: '/tmp' })) {
|
|
140
|
+
// drain
|
|
141
|
+
}
|
|
142
|
+
const callArgs = execaMock.mock.calls[0]?.[1] ?? [];
|
|
143
|
+
expect(callArgs).toContain('--strict-mcp-config');
|
|
144
|
+
});
|
|
145
|
+
it('stream-json prefers result event text over merged deltas', async () => {
|
|
146
|
+
const execaMock = execa;
|
|
147
|
+
execaMock.mockImplementation(() => makeProcessStreamJson({
|
|
148
|
+
lines: [
|
|
149
|
+
JSON.stringify({ type: 'message_delta', text: 'thinking...' }),
|
|
150
|
+
JSON.stringify({ type: 'message_delta', text: '<tool_use>read file</tool_use>' }),
|
|
151
|
+
JSON.stringify({ type: 'message_delta', text: 'The answer is 42.' }),
|
|
152
|
+
JSON.stringify({ type: 'result', result: 'The answer is 42.' }),
|
|
153
|
+
],
|
|
154
|
+
exitCode: 0,
|
|
155
|
+
}));
|
|
156
|
+
const rt = createClaudeCliRuntime({
|
|
157
|
+
claudeBin: 'claude',
|
|
158
|
+
dangerouslySkipPermissions: true,
|
|
159
|
+
outputFormat: 'stream-json',
|
|
160
|
+
});
|
|
161
|
+
const events = [];
|
|
162
|
+
for await (const evt of rt.invoke({
|
|
163
|
+
prompt: 'p',
|
|
164
|
+
model: 'opus',
|
|
165
|
+
cwd: '/tmp',
|
|
166
|
+
})) {
|
|
167
|
+
events.push(evt);
|
|
168
|
+
}
|
|
169
|
+
// Should use the clean result text, not the merged deltas with tool_use blocks.
|
|
170
|
+
expect(events.find((e) => e.type === 'text_final')?.text).toBe('The answer is 42.');
|
|
171
|
+
});
|
|
172
|
+
it('--strict-mcp-config is omitted when disabled', async () => {
|
|
173
|
+
const execaMock = execa;
|
|
174
|
+
execaMock.mockImplementation(() => makeProcessText({ stdout: 'ok', exitCode: 0 }));
|
|
175
|
+
const rt = createClaudeCliRuntime({
|
|
176
|
+
claudeBin: 'claude',
|
|
177
|
+
dangerouslySkipPermissions: false,
|
|
178
|
+
outputFormat: 'text',
|
|
179
|
+
strictMcpConfig: false,
|
|
180
|
+
});
|
|
181
|
+
for await (const _evt of rt.invoke({ prompt: 'p', model: 'opus', cwd: '/tmp' })) {
|
|
182
|
+
// drain
|
|
183
|
+
}
|
|
184
|
+
const callArgs = execaMock.mock.calls[0]?.[1] ?? [];
|
|
185
|
+
expect(callArgs).not.toContain('--strict-mcp-config');
|
|
186
|
+
});
|
|
187
|
+
it('--fallback-model is passed when set', async () => {
|
|
188
|
+
const execaMock = execa;
|
|
189
|
+
execaMock.mockImplementation(() => makeProcessText({ stdout: 'ok', exitCode: 0 }));
|
|
190
|
+
const rt = createClaudeCliRuntime({
|
|
191
|
+
claudeBin: 'claude',
|
|
192
|
+
dangerouslySkipPermissions: false,
|
|
193
|
+
outputFormat: 'text',
|
|
194
|
+
fallbackModel: 'sonnet',
|
|
195
|
+
});
|
|
196
|
+
for await (const _evt of rt.invoke({ prompt: 'p', model: 'opus', cwd: '/tmp' })) {
|
|
197
|
+
// drain
|
|
198
|
+
}
|
|
199
|
+
const callArgs = execaMock.mock.calls[0]?.[1] ?? [];
|
|
200
|
+
expect(callArgs).toContain('--fallback-model');
|
|
201
|
+
expect(callArgs[callArgs.indexOf('--fallback-model') + 1]).toBe('sonnet');
|
|
202
|
+
});
|
|
203
|
+
it('--fallback-model is omitted when unset', async () => {
|
|
204
|
+
const execaMock = execa;
|
|
205
|
+
execaMock.mockImplementation(() => makeProcessText({ stdout: 'ok', exitCode: 0 }));
|
|
206
|
+
const rt = createClaudeCliRuntime({
|
|
207
|
+
claudeBin: 'claude',
|
|
208
|
+
dangerouslySkipPermissions: false,
|
|
209
|
+
outputFormat: 'text',
|
|
210
|
+
});
|
|
211
|
+
for await (const _evt of rt.invoke({ prompt: 'p', model: 'opus', cwd: '/tmp' })) {
|
|
212
|
+
// drain
|
|
213
|
+
}
|
|
214
|
+
const callArgs = execaMock.mock.calls[0]?.[1] ?? [];
|
|
215
|
+
expect(callArgs).not.toContain('--fallback-model');
|
|
216
|
+
});
|
|
217
|
+
it('--max-budget-usd is passed when set', async () => {
|
|
218
|
+
const execaMock = execa;
|
|
219
|
+
execaMock.mockImplementation(() => makeProcessText({ stdout: 'ok', exitCode: 0 }));
|
|
220
|
+
const rt = createClaudeCliRuntime({
|
|
221
|
+
claudeBin: 'claude',
|
|
222
|
+
dangerouslySkipPermissions: false,
|
|
223
|
+
outputFormat: 'text',
|
|
224
|
+
maxBudgetUsd: 5,
|
|
225
|
+
});
|
|
226
|
+
for await (const _evt of rt.invoke({ prompt: 'p', model: 'opus', cwd: '/tmp' })) {
|
|
227
|
+
// drain
|
|
228
|
+
}
|
|
229
|
+
const callArgs = execaMock.mock.calls[0]?.[1] ?? [];
|
|
230
|
+
expect(callArgs).toContain('--max-budget-usd');
|
|
231
|
+
expect(callArgs[callArgs.indexOf('--max-budget-usd') + 1]).toBe('5');
|
|
232
|
+
});
|
|
233
|
+
it('--max-budget-usd is omitted when unset', async () => {
|
|
234
|
+
const execaMock = execa;
|
|
235
|
+
execaMock.mockImplementation(() => makeProcessText({ stdout: 'ok', exitCode: 0 }));
|
|
236
|
+
const rt = createClaudeCliRuntime({
|
|
237
|
+
claudeBin: 'claude',
|
|
238
|
+
dangerouslySkipPermissions: false,
|
|
239
|
+
outputFormat: 'text',
|
|
240
|
+
});
|
|
241
|
+
for await (const _evt of rt.invoke({ prompt: 'p', model: 'opus', cwd: '/tmp' })) {
|
|
242
|
+
// drain
|
|
243
|
+
}
|
|
244
|
+
const callArgs = execaMock.mock.calls[0]?.[1] ?? [];
|
|
245
|
+
expect(callArgs).not.toContain('--max-budget-usd');
|
|
246
|
+
});
|
|
247
|
+
it('--append-system-prompt is passed when set', async () => {
|
|
248
|
+
const execaMock = execa;
|
|
249
|
+
execaMock.mockImplementation(() => makeProcessText({ stdout: 'ok', exitCode: 0 }));
|
|
250
|
+
const rt = createClaudeCliRuntime({
|
|
251
|
+
claudeBin: 'claude',
|
|
252
|
+
dangerouslySkipPermissions: false,
|
|
253
|
+
outputFormat: 'text',
|
|
254
|
+
appendSystemPrompt: 'You are Weston.',
|
|
255
|
+
});
|
|
256
|
+
for await (const _evt of rt.invoke({ prompt: 'p', model: 'opus', cwd: '/tmp' })) {
|
|
257
|
+
// drain
|
|
258
|
+
}
|
|
259
|
+
const callArgs = execaMock.mock.calls[0]?.[1] ?? [];
|
|
260
|
+
expect(callArgs).toContain('--append-system-prompt');
|
|
261
|
+
expect(callArgs[callArgs.indexOf('--append-system-prompt') + 1]).toBe('You are Weston.');
|
|
262
|
+
});
|
|
263
|
+
it('--append-system-prompt is omitted when unset', async () => {
|
|
264
|
+
const execaMock = execa;
|
|
265
|
+
execaMock.mockImplementation(() => makeProcessText({ stdout: 'ok', exitCode: 0 }));
|
|
266
|
+
const rt = createClaudeCliRuntime({
|
|
267
|
+
claudeBin: 'claude',
|
|
268
|
+
dangerouslySkipPermissions: false,
|
|
269
|
+
outputFormat: 'text',
|
|
270
|
+
});
|
|
271
|
+
for await (const _evt of rt.invoke({ prompt: 'p', model: 'opus', cwd: '/tmp' })) {
|
|
272
|
+
// drain
|
|
273
|
+
}
|
|
274
|
+
const callArgs = execaMock.mock.calls[0]?.[1] ?? [];
|
|
275
|
+
expect(callArgs).not.toContain('--append-system-prompt');
|
|
276
|
+
});
|
|
277
|
+
it('--verbose is passed when enabled', async () => {
|
|
278
|
+
const execaMock = execa;
|
|
279
|
+
execaMock.mockImplementation(() => makeProcessText({ stdout: 'ok', exitCode: 0 }));
|
|
280
|
+
// Note: verbose: true with outputFormat: 'text' is prevented by the config layer,
|
|
281
|
+
// but we test the runtime layer in isolation here.
|
|
282
|
+
const rt = createClaudeCliRuntime({
|
|
283
|
+
claudeBin: 'claude',
|
|
284
|
+
dangerouslySkipPermissions: false,
|
|
285
|
+
outputFormat: 'stream-json',
|
|
286
|
+
verbose: true,
|
|
287
|
+
});
|
|
288
|
+
for await (const _evt of rt.invoke({ prompt: 'p', model: 'opus', cwd: '/tmp' })) {
|
|
289
|
+
// drain
|
|
290
|
+
}
|
|
291
|
+
const callArgs = execaMock.mock.calls[0]?.[1] ?? [];
|
|
292
|
+
expect(callArgs).toContain('--verbose');
|
|
293
|
+
});
|
|
294
|
+
it('--verbose is omitted when disabled', async () => {
|
|
295
|
+
const execaMock = execa;
|
|
296
|
+
execaMock.mockImplementation(() => makeProcessText({ stdout: 'ok', exitCode: 0 }));
|
|
297
|
+
const rt = createClaudeCliRuntime({
|
|
298
|
+
claudeBin: 'claude',
|
|
299
|
+
dangerouslySkipPermissions: false,
|
|
300
|
+
outputFormat: 'text',
|
|
301
|
+
verbose: false,
|
|
302
|
+
});
|
|
303
|
+
for await (const _evt of rt.invoke({ prompt: 'p', model: 'opus', cwd: '/tmp' })) {
|
|
304
|
+
// drain
|
|
305
|
+
}
|
|
306
|
+
const callArgs = execaMock.mock.calls[0]?.[1] ?? [];
|
|
307
|
+
expect(callArgs).not.toContain('--verbose');
|
|
308
|
+
});
|
|
309
|
+
it('--verbose is omitted when unset', async () => {
|
|
310
|
+
const execaMock = execa;
|
|
311
|
+
execaMock.mockImplementation(() => makeProcessText({ stdout: 'ok', exitCode: 0 }));
|
|
312
|
+
const rt = createClaudeCliRuntime({
|
|
313
|
+
claudeBin: 'claude',
|
|
314
|
+
dangerouslySkipPermissions: false,
|
|
315
|
+
outputFormat: 'text',
|
|
316
|
+
});
|
|
317
|
+
for await (const _evt of rt.invoke({ prompt: 'p', model: 'opus', cwd: '/tmp' })) {
|
|
318
|
+
// drain
|
|
319
|
+
}
|
|
320
|
+
const callArgs = execaMock.mock.calls[0]?.[1] ?? [];
|
|
321
|
+
expect(callArgs).not.toContain('--verbose');
|
|
322
|
+
});
|
|
323
|
+
it('stream-json emits image_data from streaming content blocks', async () => {
|
|
324
|
+
const execaMock = execa;
|
|
325
|
+
execaMock.mockImplementation(() => makeProcessStreamJson({
|
|
326
|
+
lines: [
|
|
327
|
+
JSON.stringify({ type: 'message_delta', text: 'Here is an image:' }),
|
|
328
|
+
JSON.stringify({ type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'iVBORw0KGgo=' } }),
|
|
329
|
+
],
|
|
330
|
+
exitCode: 0,
|
|
331
|
+
}));
|
|
332
|
+
const rt = createClaudeCliRuntime({
|
|
333
|
+
claudeBin: 'claude',
|
|
334
|
+
dangerouslySkipPermissions: true,
|
|
335
|
+
outputFormat: 'stream-json',
|
|
336
|
+
});
|
|
337
|
+
const events = [];
|
|
338
|
+
for await (const evt of rt.invoke({ prompt: 'p', model: 'opus', cwd: '/tmp' })) {
|
|
339
|
+
events.push(evt);
|
|
340
|
+
}
|
|
341
|
+
const imageEvents = events.filter((e) => e.type === 'image_data');
|
|
342
|
+
expect(imageEvents).toHaveLength(1);
|
|
343
|
+
expect(imageEvents[0].image.mediaType).toBe('image/png');
|
|
344
|
+
expect(imageEvents[0].image.base64).toBe('iVBORw0KGgo=');
|
|
345
|
+
});
|
|
346
|
+
it('stream-json emits image_data from result content block arrays', async () => {
|
|
347
|
+
const execaMock = execa;
|
|
348
|
+
execaMock.mockImplementation(() => makeProcessStreamJson({
|
|
349
|
+
lines: [
|
|
350
|
+
JSON.stringify({
|
|
351
|
+
type: 'result',
|
|
352
|
+
result: [
|
|
353
|
+
{ type: 'text', text: 'Generated image:' },
|
|
354
|
+
{ type: 'image', source: { type: 'base64', media_type: 'image/jpeg', data: '/9j/4AAQ' } },
|
|
355
|
+
],
|
|
356
|
+
}),
|
|
357
|
+
],
|
|
358
|
+
exitCode: 0,
|
|
359
|
+
}));
|
|
360
|
+
const rt = createClaudeCliRuntime({
|
|
361
|
+
claudeBin: 'claude',
|
|
362
|
+
dangerouslySkipPermissions: true,
|
|
363
|
+
outputFormat: 'stream-json',
|
|
364
|
+
});
|
|
365
|
+
const events = [];
|
|
366
|
+
for await (const evt of rt.invoke({ prompt: 'p', model: 'opus', cwd: '/tmp' })) {
|
|
367
|
+
events.push(evt);
|
|
368
|
+
}
|
|
369
|
+
const imageEvents = events.filter((e) => e.type === 'image_data');
|
|
370
|
+
expect(imageEvents).toHaveLength(1);
|
|
371
|
+
expect(imageEvents[0].image.mediaType).toBe('image/jpeg');
|
|
372
|
+
expect(events.find((e) => e.type === 'text_final')?.text).toBe('Generated image:');
|
|
373
|
+
});
|
|
374
|
+
it('deduplicates identical images from streaming and result events', async () => {
|
|
375
|
+
const imgData = 'iVBORw0KGgo=';
|
|
376
|
+
const execaMock = execa;
|
|
377
|
+
execaMock.mockImplementation(() => makeProcessStreamJson({
|
|
378
|
+
lines: [
|
|
379
|
+
JSON.stringify({ type: 'image', source: { type: 'base64', media_type: 'image/png', data: imgData } }),
|
|
380
|
+
JSON.stringify({
|
|
381
|
+
type: 'result',
|
|
382
|
+
result: [
|
|
383
|
+
{ type: 'text', text: 'Done' },
|
|
384
|
+
{ type: 'image', source: { type: 'base64', media_type: 'image/png', data: imgData } },
|
|
385
|
+
],
|
|
386
|
+
}),
|
|
387
|
+
],
|
|
388
|
+
exitCode: 0,
|
|
389
|
+
}));
|
|
390
|
+
const rt = createClaudeCliRuntime({
|
|
391
|
+
claudeBin: 'claude',
|
|
392
|
+
dangerouslySkipPermissions: true,
|
|
393
|
+
outputFormat: 'stream-json',
|
|
394
|
+
});
|
|
395
|
+
const events = [];
|
|
396
|
+
for await (const evt of rt.invoke({ prompt: 'p', model: 'opus', cwd: '/tmp' })) {
|
|
397
|
+
events.push(evt);
|
|
398
|
+
}
|
|
399
|
+
const imageEvents = events.filter((e) => e.type === 'image_data');
|
|
400
|
+
expect(imageEvents).toHaveLength(1);
|
|
401
|
+
});
|
|
402
|
+
});
|
|
403
|
+
describe('extractImageFromUnknownEvent', () => {
|
|
404
|
+
it('extracts direct image content block', () => {
|
|
405
|
+
const evt = { type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'abc123' } };
|
|
406
|
+
const result = extractImageFromUnknownEvent(evt);
|
|
407
|
+
expect(result).toEqual({ base64: 'abc123', mediaType: 'image/png' });
|
|
408
|
+
});
|
|
409
|
+
it('extracts content_block_start wrapper', () => {
|
|
410
|
+
const evt = { content_block: { type: 'image', source: { type: 'base64', media_type: 'image/webp', data: 'xyz' } } };
|
|
411
|
+
const result = extractImageFromUnknownEvent(evt);
|
|
412
|
+
expect(result).toEqual({ base64: 'xyz', mediaType: 'image/webp' });
|
|
413
|
+
});
|
|
414
|
+
it('returns null for missing fields', () => {
|
|
415
|
+
expect(extractImageFromUnknownEvent(null)).toBeNull();
|
|
416
|
+
expect(extractImageFromUnknownEvent({})).toBeNull();
|
|
417
|
+
expect(extractImageFromUnknownEvent({ type: 'image' })).toBeNull();
|
|
418
|
+
expect(extractImageFromUnknownEvent({ type: 'image', source: { type: 'url' } })).toBeNull();
|
|
419
|
+
});
|
|
420
|
+
it('returns null for oversized base64', () => {
|
|
421
|
+
const bigData = 'a'.repeat(26 * 1024 * 1024);
|
|
422
|
+
const evt = { type: 'image', source: { type: 'base64', media_type: 'image/png', data: bigData } };
|
|
423
|
+
expect(extractImageFromUnknownEvent(evt)).toBeNull();
|
|
424
|
+
});
|
|
425
|
+
});
|
|
426
|
+
describe('extractResultContentBlocks', () => {
|
|
427
|
+
it('extracts text and images from array result', () => {
|
|
428
|
+
const evt = {
|
|
429
|
+
type: 'result',
|
|
430
|
+
result: [
|
|
431
|
+
{ type: 'text', text: 'Hello' },
|
|
432
|
+
{ type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'abc' } },
|
|
433
|
+
],
|
|
434
|
+
};
|
|
435
|
+
const result = extractResultContentBlocks(evt);
|
|
436
|
+
expect(result).not.toBeNull();
|
|
437
|
+
expect(result.text).toBe('Hello');
|
|
438
|
+
expect(result.images).toHaveLength(1);
|
|
439
|
+
expect(result.images[0].mediaType).toBe('image/png');
|
|
440
|
+
});
|
|
441
|
+
it('returns null for plain string result', () => {
|
|
442
|
+
const evt = { type: 'result', result: 'just text' };
|
|
443
|
+
expect(extractResultContentBlocks(evt)).toBeNull();
|
|
444
|
+
});
|
|
445
|
+
it('returns null for non-result event', () => {
|
|
446
|
+
expect(extractResultContentBlocks({ type: 'message_delta', text: 'hi' })).toBeNull();
|
|
447
|
+
});
|
|
448
|
+
it('handles empty array', () => {
|
|
449
|
+
const result = extractResultContentBlocks({ type: 'result', result: [] });
|
|
450
|
+
expect(result).not.toBeNull();
|
|
451
|
+
expect(result.text).toBe('');
|
|
452
|
+
expect(result.images).toHaveLength(0);
|
|
453
|
+
});
|
|
454
|
+
});
|
|
455
|
+
describe('imageDedupeKey', () => {
|
|
456
|
+
it('creates consistent key for same image', () => {
|
|
457
|
+
const img = { base64: 'abc', mediaType: 'image/png' };
|
|
458
|
+
expect(imageDedupeKey(img)).toBe('image/png:3:abc');
|
|
459
|
+
expect(imageDedupeKey(img)).toBe(imageDedupeKey({ ...img }));
|
|
460
|
+
});
|
|
461
|
+
it('different images produce different keys', () => {
|
|
462
|
+
const a = { base64: 'abc', mediaType: 'image/png' };
|
|
463
|
+
const b = { base64: 'xyz', mediaType: 'image/png' };
|
|
464
|
+
expect(imageDedupeKey(a)).not.toBe(imageDedupeKey(b));
|
|
465
|
+
});
|
|
466
|
+
it('uses prefix + length to avoid storing full base64', () => {
|
|
467
|
+
const longData = 'a'.repeat(1000);
|
|
468
|
+
const img = { base64: longData, mediaType: 'image/png' };
|
|
469
|
+
const key = imageDedupeKey(img);
|
|
470
|
+
// Key should be much shorter than the full base64 string
|
|
471
|
+
expect(key.length).toBeLessThan(200);
|
|
472
|
+
expect(key).toContain(':1000:');
|
|
473
|
+
});
|
|
474
|
+
it('distinguishes images with same prefix but different lengths', () => {
|
|
475
|
+
const a = { base64: 'a'.repeat(100), mediaType: 'image/png' };
|
|
476
|
+
const b = { base64: 'a'.repeat(200), mediaType: 'image/png' };
|
|
477
|
+
expect(imageDedupeKey(a)).not.toBe(imageDedupeKey(b));
|
|
478
|
+
});
|
|
479
|
+
});
|
|
480
|
+
describe('one-shot with images', () => {
|
|
481
|
+
function makeProcessStreamJsonWithStdin(args) {
|
|
482
|
+
const p = Promise.resolve({ exitCode: args.exitCode });
|
|
483
|
+
p.stdout = Readable.from(args.lines.map((l) => l + '\n'));
|
|
484
|
+
p.stderr = Readable.from([]);
|
|
485
|
+
p.stdin = { write: vi.fn(), end: vi.fn() };
|
|
486
|
+
return p;
|
|
487
|
+
}
|
|
488
|
+
it('uses stdin pipe + stream-json input when images are present', async () => {
|
|
489
|
+
const execaMock = execa;
|
|
490
|
+
execaMock.mockImplementation(() => makeProcessStreamJsonWithStdin({
|
|
491
|
+
lines: [
|
|
492
|
+
JSON.stringify({ type: 'message_delta', text: 'I see a cat' }),
|
|
493
|
+
JSON.stringify({ type: 'result', result: 'I see a cat' }),
|
|
494
|
+
],
|
|
495
|
+
exitCode: 0,
|
|
496
|
+
}));
|
|
497
|
+
const rt = createClaudeCliRuntime({
|
|
498
|
+
claudeBin: 'claude',
|
|
499
|
+
dangerouslySkipPermissions: true,
|
|
500
|
+
outputFormat: 'stream-json',
|
|
501
|
+
});
|
|
502
|
+
const events = [];
|
|
503
|
+
for await (const evt of rt.invoke({
|
|
504
|
+
prompt: 'What is in this image?',
|
|
505
|
+
model: 'opus',
|
|
506
|
+
cwd: '/tmp',
|
|
507
|
+
images: [{ base64: 'iVBORw0KGgo=', mediaType: 'image/png' }],
|
|
508
|
+
})) {
|
|
509
|
+
events.push(evt);
|
|
510
|
+
}
|
|
511
|
+
expect(events.find((e) => e.type === 'text_final')?.text).toBe('I see a cat');
|
|
512
|
+
// Verify args
|
|
513
|
+
const callArgs = execaMock.mock.calls[0]?.[1] ?? [];
|
|
514
|
+
expect(callArgs).toContain('--input-format');
|
|
515
|
+
expect(callArgs).toContain('stream-json');
|
|
516
|
+
// Prompt should NOT be in positional args
|
|
517
|
+
expect(callArgs).not.toContain('--');
|
|
518
|
+
expect(callArgs).not.toContain('What is in this image?');
|
|
519
|
+
// Verify stdin options
|
|
520
|
+
const callOpts = execaMock.mock.calls[0]?.[2] ?? {};
|
|
521
|
+
expect(callOpts.stdin).toBe('pipe');
|
|
522
|
+
// Verify stdin was written with content blocks
|
|
523
|
+
const proc = execaMock.mock.results[0].value;
|
|
524
|
+
expect(proc.stdin.write).toHaveBeenCalledOnce();
|
|
525
|
+
const written = proc.stdin.write.mock.calls[0][0];
|
|
526
|
+
const parsed = JSON.parse(written.trim());
|
|
527
|
+
expect(parsed.type).toBe('user');
|
|
528
|
+
expect(Array.isArray(parsed.message.content)).toBe(true);
|
|
529
|
+
expect(parsed.message.content[0]).toEqual({ type: 'text', text: 'What is in this image?' });
|
|
530
|
+
expect(parsed.message.content[1].type).toBe('image');
|
|
531
|
+
expect(parsed.message.content[1].source.media_type).toBe('image/png');
|
|
532
|
+
expect(proc.stdin.end).toHaveBeenCalledOnce();
|
|
533
|
+
});
|
|
534
|
+
it('without images: uses positional arg and stdin ignore (no regression)', async () => {
|
|
535
|
+
const execaMock = execa;
|
|
536
|
+
execaMock.mockImplementation(() => makeProcessText({ stdout: 'hello', exitCode: 0 }));
|
|
537
|
+
const rt = createClaudeCliRuntime({
|
|
538
|
+
claudeBin: 'claude',
|
|
539
|
+
dangerouslySkipPermissions: false,
|
|
540
|
+
outputFormat: 'text',
|
|
541
|
+
});
|
|
542
|
+
const events = [];
|
|
543
|
+
for await (const evt of rt.invoke({
|
|
544
|
+
prompt: 'plain text prompt',
|
|
545
|
+
model: 'opus',
|
|
546
|
+
cwd: '/tmp',
|
|
547
|
+
})) {
|
|
548
|
+
events.push(evt);
|
|
549
|
+
}
|
|
550
|
+
expect(events.find((e) => e.type === 'text_final')?.text).toBe('hello');
|
|
551
|
+
const callArgs = execaMock.mock.calls[0]?.[1] ?? [];
|
|
552
|
+
// Prompt must follow `--` separator
|
|
553
|
+
const sepIdx = callArgs.indexOf('--');
|
|
554
|
+
expect(sepIdx).toBeGreaterThanOrEqual(0);
|
|
555
|
+
expect(callArgs[sepIdx + 1]).toBe('plain text prompt');
|
|
556
|
+
// Should NOT have --input-format
|
|
557
|
+
expect(callArgs).not.toContain('--input-format');
|
|
558
|
+
const callOpts = execaMock.mock.calls[0]?.[2] ?? {};
|
|
559
|
+
expect(callOpts.stdin).toBe('ignore');
|
|
560
|
+
});
|
|
561
|
+
it('no duplicate --output-format when images override text format', async () => {
|
|
562
|
+
const execaMock = execa;
|
|
563
|
+
execaMock.mockImplementation(() => makeProcessStreamJsonWithStdin({
|
|
564
|
+
lines: [
|
|
565
|
+
JSON.stringify({ type: 'result', result: 'ok' }),
|
|
566
|
+
],
|
|
567
|
+
exitCode: 0,
|
|
568
|
+
}));
|
|
569
|
+
const rt = createClaudeCliRuntime({
|
|
570
|
+
claudeBin: 'claude',
|
|
571
|
+
dangerouslySkipPermissions: true,
|
|
572
|
+
outputFormat: 'text',
|
|
573
|
+
});
|
|
574
|
+
for await (const _evt of rt.invoke({
|
|
575
|
+
prompt: 'describe',
|
|
576
|
+
model: 'opus',
|
|
577
|
+
cwd: '/tmp',
|
|
578
|
+
images: [{ base64: 'abc', mediaType: 'image/jpeg' }],
|
|
579
|
+
})) {
|
|
580
|
+
// drain
|
|
581
|
+
}
|
|
582
|
+
const callArgs = execaMock.mock.calls[0]?.[1] ?? [];
|
|
583
|
+
const outputFormatCount = callArgs.filter((a) => a === '--output-format').length;
|
|
584
|
+
expect(outputFormatCount).toBe(1);
|
|
585
|
+
});
|
|
586
|
+
it('images with text outputFormat forces stream-json output', async () => {
|
|
587
|
+
const execaMock = execa;
|
|
588
|
+
execaMock.mockImplementation(() => makeProcessStreamJsonWithStdin({
|
|
589
|
+
lines: [
|
|
590
|
+
JSON.stringify({ type: 'result', result: 'Described image' }),
|
|
591
|
+
],
|
|
592
|
+
exitCode: 0,
|
|
593
|
+
}));
|
|
594
|
+
const rt = createClaudeCliRuntime({
|
|
595
|
+
claudeBin: 'claude',
|
|
596
|
+
dangerouslySkipPermissions: true,
|
|
597
|
+
outputFormat: 'text',
|
|
598
|
+
});
|
|
599
|
+
const events = [];
|
|
600
|
+
for await (const evt of rt.invoke({
|
|
601
|
+
prompt: 'describe',
|
|
602
|
+
model: 'opus',
|
|
603
|
+
cwd: '/tmp',
|
|
604
|
+
images: [{ base64: 'abc', mediaType: 'image/jpeg' }],
|
|
605
|
+
})) {
|
|
606
|
+
events.push(evt);
|
|
607
|
+
}
|
|
608
|
+
expect(events.find((e) => e.type === 'text_final')?.text).toBe('Described image');
|
|
609
|
+
// Even though opts.outputFormat is 'text', args should include stream-json output
|
|
610
|
+
const callArgs = execaMock.mock.calls[0]?.[1] ?? [];
|
|
611
|
+
expect(callArgs).toContain('--input-format');
|
|
612
|
+
// Should have --output-format stream-json added for images
|
|
613
|
+
const outputFormatIdx = callArgs.lastIndexOf('--output-format');
|
|
614
|
+
expect(callArgs[outputFormatIdx + 1]).toBe('stream-json');
|
|
615
|
+
});
|
|
616
|
+
});
|
|
617
|
+
describe('pool forwarding (multi-turn opts wiring)', () => {
|
|
618
|
+
it('forwards fallbackModel, maxBudgetUsd, and appendSystemPrompt to LongRunningProcess', async () => {
|
|
619
|
+
const execaMock = execa;
|
|
620
|
+
// The pool path will spawn a LongRunningProcess which calls execa internally.
|
|
621
|
+
// makeProcessText returns an immediately-resolved process, so the LRP detects a
|
|
622
|
+
// dead subprocess and falls back to one-shot. We verify both calls: the first
|
|
623
|
+
// (LRP spawn) should have multi-turn flags, the second (one-shot fallback) should
|
|
624
|
+
// have the same runtime opts.
|
|
625
|
+
execaMock.mockImplementation(() => {
|
|
626
|
+
return makeProcessText({ stdout: 'ok', exitCode: 0 });
|
|
627
|
+
});
|
|
628
|
+
const rt = createClaudeCliRuntime({
|
|
629
|
+
claudeBin: 'claude',
|
|
630
|
+
dangerouslySkipPermissions: true,
|
|
631
|
+
outputFormat: 'text',
|
|
632
|
+
fallbackModel: 'sonnet',
|
|
633
|
+
maxBudgetUsd: 10,
|
|
634
|
+
appendSystemPrompt: 'You are a helpful PA.',
|
|
635
|
+
multiTurn: true,
|
|
636
|
+
multiTurnMaxProcesses: 2,
|
|
637
|
+
multiTurnHangTimeoutMs: 1000,
|
|
638
|
+
multiTurnIdleTimeoutMs: 5000,
|
|
639
|
+
});
|
|
640
|
+
// Invoke with a sessionKey to trigger the pool path.
|
|
641
|
+
const events = [];
|
|
642
|
+
for await (const evt of rt.invoke({
|
|
643
|
+
prompt: 'test prompt',
|
|
644
|
+
model: 'opus',
|
|
645
|
+
cwd: '/tmp',
|
|
646
|
+
sessionKey: 'test-session',
|
|
647
|
+
})) {
|
|
648
|
+
events.push(evt);
|
|
649
|
+
}
|
|
650
|
+
// Must have at least 2 execa calls: LRP spawn + one-shot fallback.
|
|
651
|
+
expect(execaMock.mock.calls.length).toBeGreaterThanOrEqual(2);
|
|
652
|
+
// First call is the LRP spawn — must have --input-format stream-json (LRP signature).
|
|
653
|
+
const lrpArgs = execaMock.mock.calls[0]?.[1] ?? [];
|
|
654
|
+
expect(lrpArgs).toContain('--input-format');
|
|
655
|
+
expect(lrpArgs[lrpArgs.indexOf('--input-format') + 1]).toBe('stream-json');
|
|
656
|
+
expect(lrpArgs).toContain('--fallback-model');
|
|
657
|
+
expect(lrpArgs[lrpArgs.indexOf('--fallback-model') + 1]).toBe('sonnet');
|
|
658
|
+
expect(lrpArgs).toContain('--max-budget-usd');
|
|
659
|
+
expect(lrpArgs[lrpArgs.indexOf('--max-budget-usd') + 1]).toBe('10');
|
|
660
|
+
expect(lrpArgs).toContain('--append-system-prompt');
|
|
661
|
+
expect(lrpArgs[lrpArgs.indexOf('--append-system-prompt') + 1]).toBe('You are a helpful PA.');
|
|
662
|
+
// Second call is the one-shot fallback — should also carry the runtime opts.
|
|
663
|
+
const oneShotArgs = execaMock.mock.calls[1]?.[1] ?? [];
|
|
664
|
+
expect(oneShotArgs).toContain('--fallback-model');
|
|
665
|
+
expect(oneShotArgs[oneShotArgs.indexOf('--fallback-model') + 1]).toBe('sonnet');
|
|
666
|
+
expect(oneShotArgs).toContain('--max-budget-usd');
|
|
667
|
+
expect(oneShotArgs[oneShotArgs.indexOf('--max-budget-usd') + 1]).toBe('10');
|
|
668
|
+
expect(oneShotArgs).toContain('--append-system-prompt');
|
|
669
|
+
expect(oneShotArgs[oneShotArgs.indexOf('--append-system-prompt') + 1]).toBe('You are a helpful PA.');
|
|
670
|
+
});
|
|
671
|
+
it('forwards --verbose to LongRunningProcess', async () => {
|
|
672
|
+
const execaMock = execa;
|
|
673
|
+
execaMock.mockImplementation(() => makeProcessText({ stdout: 'ok', exitCode: 0 }));
|
|
674
|
+
const rt = createClaudeCliRuntime({
|
|
675
|
+
claudeBin: 'claude',
|
|
676
|
+
dangerouslySkipPermissions: true,
|
|
677
|
+
outputFormat: 'text',
|
|
678
|
+
verbose: true,
|
|
679
|
+
multiTurn: true,
|
|
680
|
+
multiTurnMaxProcesses: 2,
|
|
681
|
+
multiTurnHangTimeoutMs: 1000,
|
|
682
|
+
multiTurnIdleTimeoutMs: 5000,
|
|
683
|
+
});
|
|
684
|
+
const events = [];
|
|
685
|
+
for await (const evt of rt.invoke({
|
|
686
|
+
prompt: 'test prompt',
|
|
687
|
+
model: 'opus',
|
|
688
|
+
cwd: '/tmp',
|
|
689
|
+
sessionKey: 'test-session',
|
|
690
|
+
})) {
|
|
691
|
+
events.push(evt);
|
|
692
|
+
}
|
|
693
|
+
// First call is the LRP spawn (has --input-format stream-json as LRP signature).
|
|
694
|
+
const lrpArgs = execaMock.mock.calls[0]?.[1] ?? [];
|
|
695
|
+
expect(lrpArgs).toContain('--verbose');
|
|
696
|
+
// Second call (one-shot fallback) should also have --verbose.
|
|
697
|
+
if (execaMock.mock.calls.length >= 2) {
|
|
698
|
+
const oneShotArgs = execaMock.mock.calls[1]?.[1] ?? [];
|
|
699
|
+
expect(oneShotArgs).toContain('--verbose');
|
|
700
|
+
}
|
|
701
|
+
});
|
|
702
|
+
it('--verbose is omitted from LongRunningProcess when disabled', async () => {
|
|
703
|
+
const execaMock = execa;
|
|
704
|
+
execaMock.mockImplementation(() => makeProcessText({ stdout: 'ok', exitCode: 0 }));
|
|
705
|
+
const rt = createClaudeCliRuntime({
|
|
706
|
+
claudeBin: 'claude',
|
|
707
|
+
dangerouslySkipPermissions: true,
|
|
708
|
+
outputFormat: 'text',
|
|
709
|
+
verbose: false,
|
|
710
|
+
multiTurn: true,
|
|
711
|
+
multiTurnMaxProcesses: 2,
|
|
712
|
+
multiTurnHangTimeoutMs: 1000,
|
|
713
|
+
multiTurnIdleTimeoutMs: 5000,
|
|
714
|
+
});
|
|
715
|
+
const events = [];
|
|
716
|
+
for await (const evt of rt.invoke({
|
|
717
|
+
prompt: 'test prompt',
|
|
718
|
+
model: 'opus',
|
|
719
|
+
cwd: '/tmp',
|
|
720
|
+
sessionKey: 'test-session',
|
|
721
|
+
})) {
|
|
722
|
+
events.push(evt);
|
|
723
|
+
}
|
|
724
|
+
// First call is the LRP spawn.
|
|
725
|
+
const lrpArgs = execaMock.mock.calls[0]?.[1] ?? [];
|
|
726
|
+
expect(lrpArgs).not.toContain('--verbose');
|
|
727
|
+
});
|
|
728
|
+
});
|
|
729
|
+
describe('Claude strategy parseLine (stream_event format)', () => {
|
|
730
|
+
it('extracts text from stream_event text_delta', async () => {
|
|
731
|
+
const execaMock = execa;
|
|
732
|
+
execaMock.mockImplementation(() => makeProcessStreamJson({
|
|
733
|
+
lines: [
|
|
734
|
+
JSON.stringify({ type: 'stream_event', event: { type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: 'Hello' } } }),
|
|
735
|
+
JSON.stringify({ type: 'stream_event', event: { type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: ' world' } } }),
|
|
736
|
+
JSON.stringify({ type: 'result', result: 'Hello world' }),
|
|
737
|
+
],
|
|
738
|
+
exitCode: 0,
|
|
739
|
+
}));
|
|
740
|
+
const rt = createClaudeCliRuntime({
|
|
741
|
+
claudeBin: 'claude',
|
|
742
|
+
dangerouslySkipPermissions: true,
|
|
743
|
+
outputFormat: 'stream-json',
|
|
744
|
+
});
|
|
745
|
+
const events = [];
|
|
746
|
+
for await (const evt of rt.invoke({ prompt: 'p', model: 'opus', cwd: '/tmp' })) {
|
|
747
|
+
events.push(evt);
|
|
748
|
+
}
|
|
749
|
+
const deltas = events.filter((e) => e.type === 'text_delta');
|
|
750
|
+
expect(deltas).toHaveLength(2);
|
|
751
|
+
expect(deltas[0].text).toBe('Hello');
|
|
752
|
+
expect(deltas[1].text).toBe(' world');
|
|
753
|
+
expect(events.find((e) => e.type === 'text_final')?.text).toBe('Hello world');
|
|
754
|
+
});
|
|
755
|
+
it('does NOT extract text from thinking_delta events', async () => {
|
|
756
|
+
const execaMock = execa;
|
|
757
|
+
execaMock.mockImplementation(() => makeProcessStreamJson({
|
|
758
|
+
lines: [
|
|
759
|
+
JSON.stringify({ type: 'stream_event', event: { type: 'content_block_start', index: 0, content_block: { type: 'thinking', thinking: '' } } }),
|
|
760
|
+
JSON.stringify({ type: 'stream_event', event: { type: 'content_block_delta', index: 0, delta: { type: 'thinking_delta', thinking: 'Let me think about this...' } } }),
|
|
761
|
+
JSON.stringify({ type: 'stream_event', event: { type: 'content_block_stop', index: 0 } }),
|
|
762
|
+
JSON.stringify({ type: 'stream_event', event: { type: 'content_block_start', index: 1, content_block: { type: 'text', text: '' } } }),
|
|
763
|
+
JSON.stringify({ type: 'stream_event', event: { type: 'content_block_delta', index: 1, delta: { type: 'text_delta', text: 'The answer is 42.' } } }),
|
|
764
|
+
JSON.stringify({ type: 'stream_event', event: { type: 'content_block_stop', index: 1 } }),
|
|
765
|
+
JSON.stringify({ type: 'result', result: 'The answer is 42.' }),
|
|
766
|
+
],
|
|
767
|
+
exitCode: 0,
|
|
768
|
+
}));
|
|
769
|
+
const rt = createClaudeCliRuntime({
|
|
770
|
+
claudeBin: 'claude',
|
|
771
|
+
dangerouslySkipPermissions: true,
|
|
772
|
+
outputFormat: 'stream-json',
|
|
773
|
+
});
|
|
774
|
+
const events = [];
|
|
775
|
+
for await (const evt of rt.invoke({ prompt: 'p', model: 'opus', cwd: '/tmp' })) {
|
|
776
|
+
events.push(evt);
|
|
777
|
+
}
|
|
778
|
+
// Only the text_delta should produce text events — thinking_delta must be filtered out.
|
|
779
|
+
const deltas = events.filter((e) => e.type === 'text_delta');
|
|
780
|
+
expect(deltas).toHaveLength(1);
|
|
781
|
+
expect(deltas[0].text).toBe('The answer is 42.');
|
|
782
|
+
expect(events.find((e) => e.type === 'text_final')?.text).toBe('The answer is 42.');
|
|
783
|
+
});
|
|
784
|
+
it('does NOT extract text from input_json_delta (tool use)', async () => {
|
|
785
|
+
const execaMock = execa;
|
|
786
|
+
execaMock.mockImplementation(() => makeProcessStreamJson({
|
|
787
|
+
lines: [
|
|
788
|
+
JSON.stringify({ type: 'stream_event', event: { type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: 'Let me read that.' } } }),
|
|
789
|
+
JSON.stringify({ type: 'stream_event', event: { type: 'content_block_delta', index: 1, delta: { type: 'input_json_delta', partial_json: '{"file_path":"/tmp/f"}' } } }),
|
|
790
|
+
JSON.stringify({ type: 'result', result: 'Let me read that.' }),
|
|
791
|
+
],
|
|
792
|
+
exitCode: 0,
|
|
793
|
+
}));
|
|
794
|
+
const rt = createClaudeCliRuntime({
|
|
795
|
+
claudeBin: 'claude',
|
|
796
|
+
dangerouslySkipPermissions: true,
|
|
797
|
+
outputFormat: 'stream-json',
|
|
798
|
+
});
|
|
799
|
+
const events = [];
|
|
800
|
+
for await (const evt of rt.invoke({ prompt: 'p', model: 'opus', cwd: '/tmp' })) {
|
|
801
|
+
events.push(evt);
|
|
802
|
+
}
|
|
803
|
+
const deltas = events.filter((e) => e.type === 'text_delta');
|
|
804
|
+
expect(deltas).toHaveLength(1);
|
|
805
|
+
expect(deltas[0].text).toBe('Let me read that.');
|
|
806
|
+
});
|
|
807
|
+
it('consumes assistant partial messages without emitting text', async () => {
|
|
808
|
+
const execaMock = execa;
|
|
809
|
+
execaMock.mockImplementation(() => makeProcessStreamJson({
|
|
810
|
+
lines: [
|
|
811
|
+
JSON.stringify({ type: 'stream_event', event: { type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: 'hi' } } }),
|
|
812
|
+
JSON.stringify({ type: 'assistant', message: { content: [{ type: 'text', text: 'hi' }] } }),
|
|
813
|
+
JSON.stringify({ type: 'result', result: 'hi' }),
|
|
814
|
+
],
|
|
815
|
+
exitCode: 0,
|
|
816
|
+
}));
|
|
817
|
+
const rt = createClaudeCliRuntime({
|
|
818
|
+
claudeBin: 'claude',
|
|
819
|
+
dangerouslySkipPermissions: true,
|
|
820
|
+
outputFormat: 'stream-json',
|
|
821
|
+
});
|
|
822
|
+
const events = [];
|
|
823
|
+
for await (const evt of rt.invoke({ prompt: 'p', model: 'opus', cwd: '/tmp' })) {
|
|
824
|
+
events.push(evt);
|
|
825
|
+
}
|
|
826
|
+
// Only one text_delta from the content_block_delta, not a duplicate from the assistant message.
|
|
827
|
+
const deltas = events.filter((e) => e.type === 'text_delta');
|
|
828
|
+
expect(deltas).toHaveLength(1);
|
|
829
|
+
expect(deltas[0].text).toBe('hi');
|
|
830
|
+
});
|
|
831
|
+
it('emits tool_start and tool_end events for tool_use content blocks', async () => {
|
|
832
|
+
const execaMock = execa;
|
|
833
|
+
execaMock.mockImplementation(() => makeProcessStreamJson({
|
|
834
|
+
lines: [
|
|
835
|
+
JSON.stringify({ type: 'stream_event', event: { type: 'content_block_start', index: 0, content_block: { type: 'text', text: '' } } }),
|
|
836
|
+
JSON.stringify({ type: 'stream_event', event: { type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: 'Let me read that.' } } }),
|
|
837
|
+
JSON.stringify({ type: 'stream_event', event: { type: 'content_block_stop', index: 0 } }),
|
|
838
|
+
JSON.stringify({ type: 'stream_event', event: { type: 'content_block_start', index: 1, content_block: { type: 'tool_use', id: 'toolu_123', name: 'Read' } } }),
|
|
839
|
+
JSON.stringify({ type: 'stream_event', event: { type: 'content_block_delta', index: 1, delta: { type: 'input_json_delta', partial_json: '{"file_' } } }),
|
|
840
|
+
JSON.stringify({ type: 'stream_event', event: { type: 'content_block_delta', index: 1, delta: { type: 'input_json_delta', partial_json: 'path":"/tmp/foo.ts"}' } } }),
|
|
841
|
+
JSON.stringify({ type: 'stream_event', event: { type: 'content_block_stop', index: 1 } }),
|
|
842
|
+
JSON.stringify({ type: 'result', result: 'Let me read that.' }),
|
|
843
|
+
],
|
|
844
|
+
exitCode: 0,
|
|
845
|
+
}));
|
|
846
|
+
const rt = createClaudeCliRuntime({
|
|
847
|
+
claudeBin: 'claude',
|
|
848
|
+
dangerouslySkipPermissions: true,
|
|
849
|
+
outputFormat: 'stream-json',
|
|
850
|
+
});
|
|
851
|
+
const events = [];
|
|
852
|
+
for await (const evt of rt.invoke({ prompt: 'p', model: 'opus', cwd: '/tmp' })) {
|
|
853
|
+
events.push(evt);
|
|
854
|
+
}
|
|
855
|
+
// Should emit tool_start with name and parsed input, then tool_end.
|
|
856
|
+
const toolStarts = events.filter((e) => e.type === 'tool_start');
|
|
857
|
+
expect(toolStarts).toHaveLength(1);
|
|
858
|
+
expect(toolStarts[0].name).toBe('Read');
|
|
859
|
+
expect(toolStarts[0].input).toEqual({ file_path: '/tmp/foo.ts' });
|
|
860
|
+
const toolEnds = events.filter((e) => e.type === 'tool_end');
|
|
861
|
+
expect(toolEnds).toHaveLength(1);
|
|
862
|
+
expect(toolEnds[0].name).toBe('Read');
|
|
863
|
+
expect(toolEnds[0].ok).toBe(true);
|
|
864
|
+
});
|
|
865
|
+
it('emits multiple tool events for sequential tool use blocks', async () => {
|
|
866
|
+
const execaMock = execa;
|
|
867
|
+
execaMock.mockImplementation(() => makeProcessStreamJson({
|
|
868
|
+
lines: [
|
|
869
|
+
JSON.stringify({ type: 'stream_event', event: { type: 'content_block_start', index: 0, content_block: { type: 'tool_use', id: 'toolu_1', name: 'Glob' } } }),
|
|
870
|
+
JSON.stringify({ type: 'stream_event', event: { type: 'content_block_delta', index: 0, delta: { type: 'input_json_delta', partial_json: '{"pattern":"**/*.ts"}' } } }),
|
|
871
|
+
JSON.stringify({ type: 'stream_event', event: { type: 'content_block_stop', index: 0 } }),
|
|
872
|
+
JSON.stringify({ type: 'stream_event', event: { type: 'content_block_start', index: 1, content_block: { type: 'tool_use', id: 'toolu_2', name: 'Edit' } } }),
|
|
873
|
+
JSON.stringify({ type: 'stream_event', event: { type: 'content_block_delta', index: 1, delta: { type: 'input_json_delta', partial_json: '{"file_path":"/src/main.ts"}' } } }),
|
|
874
|
+
JSON.stringify({ type: 'stream_event', event: { type: 'content_block_stop', index: 1 } }),
|
|
875
|
+
JSON.stringify({ type: 'result', result: 'done' }),
|
|
876
|
+
],
|
|
877
|
+
exitCode: 0,
|
|
878
|
+
}));
|
|
879
|
+
const rt = createClaudeCliRuntime({
|
|
880
|
+
claudeBin: 'claude',
|
|
881
|
+
dangerouslySkipPermissions: true,
|
|
882
|
+
outputFormat: 'stream-json',
|
|
883
|
+
});
|
|
884
|
+
const events = [];
|
|
885
|
+
for await (const evt of rt.invoke({ prompt: 'p', model: 'opus', cwd: '/tmp' })) {
|
|
886
|
+
events.push(evt);
|
|
887
|
+
}
|
|
888
|
+
const toolStarts = events.filter((e) => e.type === 'tool_start');
|
|
889
|
+
expect(toolStarts).toHaveLength(2);
|
|
890
|
+
expect(toolStarts[0].name).toBe('Glob');
|
|
891
|
+
expect(toolStarts[1].name).toBe('Edit');
|
|
892
|
+
expect(toolStarts[1].input).toEqual({ file_path: '/src/main.ts' });
|
|
893
|
+
});
|
|
894
|
+
});
|
|
895
|
+
describe('one-shot stream stall timer', () => {
|
|
896
|
+
beforeEach(() => {
|
|
897
|
+
vi.useFakeTimers();
|
|
898
|
+
});
|
|
899
|
+
afterEach(() => {
|
|
900
|
+
vi.useRealTimers();
|
|
901
|
+
});
|
|
902
|
+
/** Create a mock process with controllable stdout/stderr streams and deferred exit. */
|
|
903
|
+
function makeControllableProcess() {
|
|
904
|
+
const stdout = new PassThrough();
|
|
905
|
+
const stderr = new PassThrough();
|
|
906
|
+
let resolveProcess;
|
|
907
|
+
const processPromise = new Promise((r) => { resolveProcess = r; });
|
|
908
|
+
processPromise.stdout = stdout;
|
|
909
|
+
processPromise.stderr = stderr;
|
|
910
|
+
processPromise.stdin = 'ignore';
|
|
911
|
+
processPromise.kill = vi.fn(() => {
|
|
912
|
+
stdout.end();
|
|
913
|
+
stderr.end();
|
|
914
|
+
resolveProcess({ exitCode: null, failed: true, killed: true });
|
|
915
|
+
});
|
|
916
|
+
processPromise.then = processPromise.then.bind(processPromise);
|
|
917
|
+
processPromise.catch = processPromise.catch.bind(processPromise);
|
|
918
|
+
return { proc: processPromise, stdout, stderr, resolve: resolveProcess, kill: processPromise.kill };
|
|
919
|
+
}
|
|
920
|
+
it('fires stall timer and kills process when no stdout arrives', async () => {
|
|
921
|
+
const { proc } = makeControllableProcess();
|
|
922
|
+
const execaMock = execa;
|
|
923
|
+
execaMock.mockImplementation(() => proc);
|
|
924
|
+
const rt = createClaudeCliRuntime({
|
|
925
|
+
claudeBin: 'claude',
|
|
926
|
+
dangerouslySkipPermissions: false,
|
|
927
|
+
outputFormat: 'text',
|
|
928
|
+
streamStallTimeoutMs: 5000,
|
|
929
|
+
});
|
|
930
|
+
const events = [];
|
|
931
|
+
const iter = rt.invoke({ prompt: 'p', model: 'opus', cwd: '/tmp' });
|
|
932
|
+
const drainPromise = (async () => {
|
|
933
|
+
for await (const evt of iter)
|
|
934
|
+
events.push(evt);
|
|
935
|
+
})();
|
|
936
|
+
// Advance past the stall timeout.
|
|
937
|
+
await vi.advanceTimersByTimeAsync(6000);
|
|
938
|
+
await drainPromise;
|
|
939
|
+
expect(events.some((e) => e.type === 'error' && e.message.includes('stream stall'))).toBe(true);
|
|
940
|
+
expect(events.some((e) => e.type === 'done')).toBe(true);
|
|
941
|
+
expect(proc.kill).toHaveBeenCalled();
|
|
942
|
+
});
|
|
943
|
+
it('resets stall timer when stdout data arrives', async () => {
|
|
944
|
+
const { proc, stdout, stderr, resolve } = makeControllableProcess();
|
|
945
|
+
const execaMock = execa;
|
|
946
|
+
execaMock.mockImplementation(() => proc);
|
|
947
|
+
const rt = createClaudeCliRuntime({
|
|
948
|
+
claudeBin: 'claude',
|
|
949
|
+
dangerouslySkipPermissions: false,
|
|
950
|
+
outputFormat: 'text',
|
|
951
|
+
streamStallTimeoutMs: 5000,
|
|
952
|
+
});
|
|
953
|
+
const events = [];
|
|
954
|
+
const drainPromise = (async () => {
|
|
955
|
+
for await (const evt of rt.invoke({ prompt: 'p', model: 'opus', cwd: '/tmp' }))
|
|
956
|
+
events.push(evt);
|
|
957
|
+
})();
|
|
958
|
+
// Advance 3s, push data (resets timer), then advance 3s more.
|
|
959
|
+
await vi.advanceTimersByTimeAsync(3000);
|
|
960
|
+
stdout.write('hello');
|
|
961
|
+
// Let microtasks propagate the 'data' event handler (which calls resetStallTimer).
|
|
962
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
963
|
+
await vi.advanceTimersByTimeAsync(3000);
|
|
964
|
+
// Should not have stalled (3s + 3s = 6s but timer was reset at 3s, so only 3s of silence).
|
|
965
|
+
expect(events.some((e) => e.type === 'error' && e.message.includes('stream stall'))).toBe(false);
|
|
966
|
+
// Clean up: end the process normally.
|
|
967
|
+
stdout.end();
|
|
968
|
+
stderr.end();
|
|
969
|
+
resolve({ exitCode: 0, stdout: 'hello', stderr: '' });
|
|
970
|
+
await vi.advanceTimersByTimeAsync(100);
|
|
971
|
+
await drainPromise;
|
|
972
|
+
});
|
|
973
|
+
it('resets stall timer when stderr data arrives', async () => {
|
|
974
|
+
const { proc, stderr, resolve, stdout } = makeControllableProcess();
|
|
975
|
+
const execaMock = execa;
|
|
976
|
+
execaMock.mockImplementation(() => proc);
|
|
977
|
+
const rt = createClaudeCliRuntime({
|
|
978
|
+
claudeBin: 'claude',
|
|
979
|
+
dangerouslySkipPermissions: false,
|
|
980
|
+
outputFormat: 'text',
|
|
981
|
+
streamStallTimeoutMs: 5000,
|
|
982
|
+
});
|
|
983
|
+
const events = [];
|
|
984
|
+
const drainPromise = (async () => {
|
|
985
|
+
for await (const evt of rt.invoke({ prompt: 'p', model: 'opus', cwd: '/tmp' }))
|
|
986
|
+
events.push(evt);
|
|
987
|
+
})();
|
|
988
|
+
// Advance 3s, push stderr (resets timer), then advance 3s more.
|
|
989
|
+
await vi.advanceTimersByTimeAsync(3000);
|
|
990
|
+
stderr.write('debug output');
|
|
991
|
+
await vi.advanceTimersByTimeAsync(3000);
|
|
992
|
+
expect(events.some((e) => e.type === 'error' && e.message.includes('stream stall'))).toBe(false);
|
|
993
|
+
stdout.end();
|
|
994
|
+
stderr.end();
|
|
995
|
+
resolve({ exitCode: 0, stdout: '', stderr: 'debug output' });
|
|
996
|
+
await vi.advanceTimersByTimeAsync(100);
|
|
997
|
+
await drainPromise;
|
|
998
|
+
});
|
|
999
|
+
it('does not fire stall timer when set to 0 (disabled)', async () => {
|
|
1000
|
+
const { proc, resolve, stdout, stderr } = makeControllableProcess();
|
|
1001
|
+
const execaMock = execa;
|
|
1002
|
+
execaMock.mockImplementation(() => proc);
|
|
1003
|
+
const rt = createClaudeCliRuntime({
|
|
1004
|
+
claudeBin: 'claude',
|
|
1005
|
+
dangerouslySkipPermissions: false,
|
|
1006
|
+
outputFormat: 'text',
|
|
1007
|
+
streamStallTimeoutMs: 0,
|
|
1008
|
+
});
|
|
1009
|
+
const events = [];
|
|
1010
|
+
const drainPromise = (async () => {
|
|
1011
|
+
for await (const evt of rt.invoke({ prompt: 'p', model: 'opus', cwd: '/tmp' }))
|
|
1012
|
+
events.push(evt);
|
|
1013
|
+
})();
|
|
1014
|
+
// Advance past what would be a timeout.
|
|
1015
|
+
await vi.advanceTimersByTimeAsync(200000);
|
|
1016
|
+
expect(events.some((e) => e.type === 'error' && e.message.includes('stream stall'))).toBe(false);
|
|
1017
|
+
stdout.write('ok');
|
|
1018
|
+
stdout.end();
|
|
1019
|
+
stderr.end();
|
|
1020
|
+
resolve({ exitCode: 0, stdout: 'ok', stderr: '' });
|
|
1021
|
+
await vi.advanceTimersByTimeAsync(100);
|
|
1022
|
+
await drainPromise;
|
|
1023
|
+
});
|
|
1024
|
+
it('cleans up stall timer on normal process exit', async () => {
|
|
1025
|
+
const { proc, stdout, stderr, resolve } = makeControllableProcess();
|
|
1026
|
+
const execaMock = execa;
|
|
1027
|
+
execaMock.mockImplementation(() => proc);
|
|
1028
|
+
const rt = createClaudeCliRuntime({
|
|
1029
|
+
claudeBin: 'claude',
|
|
1030
|
+
dangerouslySkipPermissions: false,
|
|
1031
|
+
outputFormat: 'text',
|
|
1032
|
+
streamStallTimeoutMs: 10000,
|
|
1033
|
+
});
|
|
1034
|
+
const events = [];
|
|
1035
|
+
const drainPromise = (async () => {
|
|
1036
|
+
for await (const evt of rt.invoke({ prompt: 'p', model: 'opus', cwd: '/tmp' }))
|
|
1037
|
+
events.push(evt);
|
|
1038
|
+
})();
|
|
1039
|
+
// Normal exit before stall timeout.
|
|
1040
|
+
stdout.write('done');
|
|
1041
|
+
stdout.end();
|
|
1042
|
+
stderr.end();
|
|
1043
|
+
resolve({ exitCode: 0, stdout: 'done', stderr: '' });
|
|
1044
|
+
await vi.advanceTimersByTimeAsync(100);
|
|
1045
|
+
await drainPromise;
|
|
1046
|
+
// Advance well past the stall timeout — should not fire (timer was cleaned up).
|
|
1047
|
+
await vi.advanceTimersByTimeAsync(20000);
|
|
1048
|
+
expect(events.some((e) => e.type === 'error' && e.message.includes('stream stall'))).toBe(false);
|
|
1049
|
+
});
|
|
1050
|
+
it('emits only one error+done when stall timer fires and process rejects', async () => {
|
|
1051
|
+
// When the stall timer fires it pushes error+done and kills the process.
|
|
1052
|
+
// If the kill causes the process promise to reject (as real execa does),
|
|
1053
|
+
// the .catch() handler must not push a second error+done pair.
|
|
1054
|
+
const stdout = new PassThrough();
|
|
1055
|
+
const stderr = new PassThrough();
|
|
1056
|
+
let rejectProcess;
|
|
1057
|
+
const processPromise = new Promise((_r, rej) => { rejectProcess = rej; });
|
|
1058
|
+
processPromise.stdout = stdout;
|
|
1059
|
+
processPromise.stderr = stderr;
|
|
1060
|
+
processPromise.stdin = 'ignore';
|
|
1061
|
+
processPromise.kill = vi.fn(() => {
|
|
1062
|
+
stdout.end();
|
|
1063
|
+
stderr.end();
|
|
1064
|
+
rejectProcess(Object.assign(new Error('killed'), { killed: true, failed: true }));
|
|
1065
|
+
});
|
|
1066
|
+
processPromise.then = processPromise.then.bind(processPromise);
|
|
1067
|
+
processPromise.catch = processPromise.catch.bind(processPromise);
|
|
1068
|
+
const execaMock = execa;
|
|
1069
|
+
execaMock.mockImplementation(() => processPromise);
|
|
1070
|
+
const rt = createClaudeCliRuntime({
|
|
1071
|
+
claudeBin: 'claude',
|
|
1072
|
+
dangerouslySkipPermissions: false,
|
|
1073
|
+
outputFormat: 'text',
|
|
1074
|
+
streamStallTimeoutMs: 5000,
|
|
1075
|
+
});
|
|
1076
|
+
const events = [];
|
|
1077
|
+
const drainPromise = (async () => {
|
|
1078
|
+
for await (const evt of rt.invoke({ prompt: 'p', model: 'opus', cwd: '/tmp' }))
|
|
1079
|
+
events.push(evt);
|
|
1080
|
+
})();
|
|
1081
|
+
await vi.advanceTimersByTimeAsync(6000);
|
|
1082
|
+
await drainPromise;
|
|
1083
|
+
const errors = events.filter((e) => e.type === 'error');
|
|
1084
|
+
const dones = events.filter((e) => e.type === 'done');
|
|
1085
|
+
expect(errors).toHaveLength(1);
|
|
1086
|
+
expect(dones).toHaveLength(1);
|
|
1087
|
+
expect(errors[0].message).toContain('stream stall');
|
|
1088
|
+
});
|
|
1089
|
+
it('works with stream-json output format', async () => {
|
|
1090
|
+
const { proc } = makeControllableProcess();
|
|
1091
|
+
const execaMock = execa;
|
|
1092
|
+
execaMock.mockImplementation(() => proc);
|
|
1093
|
+
const rt = createClaudeCliRuntime({
|
|
1094
|
+
claudeBin: 'claude',
|
|
1095
|
+
dangerouslySkipPermissions: false,
|
|
1096
|
+
outputFormat: 'stream-json',
|
|
1097
|
+
streamStallTimeoutMs: 5000,
|
|
1098
|
+
});
|
|
1099
|
+
const events = [];
|
|
1100
|
+
const drainPromise = (async () => {
|
|
1101
|
+
for await (const evt of rt.invoke({ prompt: 'p', model: 'opus', cwd: '/tmp' }))
|
|
1102
|
+
events.push(evt);
|
|
1103
|
+
})();
|
|
1104
|
+
await vi.advanceTimersByTimeAsync(6000);
|
|
1105
|
+
await drainPromise;
|
|
1106
|
+
expect(events.some((e) => e.type === 'error' && e.message.includes('stream stall'))).toBe(true);
|
|
1107
|
+
});
|
|
1108
|
+
});
|
|
1109
|
+
describe('progress stall timer (thinking spiral guard)', () => {
|
|
1110
|
+
beforeEach(() => {
|
|
1111
|
+
vi.useFakeTimers();
|
|
1112
|
+
});
|
|
1113
|
+
afterEach(() => {
|
|
1114
|
+
vi.useRealTimers();
|
|
1115
|
+
});
|
|
1116
|
+
function makeControllableProcess() {
|
|
1117
|
+
const stdout = new PassThrough();
|
|
1118
|
+
const stderr = new PassThrough();
|
|
1119
|
+
let resolveProcess;
|
|
1120
|
+
const processPromise = new Promise((r) => { resolveProcess = r; });
|
|
1121
|
+
processPromise.stdout = stdout;
|
|
1122
|
+
processPromise.stderr = stderr;
|
|
1123
|
+
processPromise.stdin = 'ignore';
|
|
1124
|
+
processPromise.kill = vi.fn(() => {
|
|
1125
|
+
stdout.end();
|
|
1126
|
+
stderr.end();
|
|
1127
|
+
resolveProcess({ exitCode: null, failed: true, killed: true });
|
|
1128
|
+
});
|
|
1129
|
+
processPromise.then = processPromise.then.bind(processPromise);
|
|
1130
|
+
processPromise.catch = processPromise.catch.bind(processPromise);
|
|
1131
|
+
return { proc: processPromise, stdout, stderr, resolve: resolveProcess, kill: processPromise.kill };
|
|
1132
|
+
}
|
|
1133
|
+
it('fires progress timer when thinking_delta flows but no text_delta', async () => {
|
|
1134
|
+
const { proc, stdout } = makeControllableProcess();
|
|
1135
|
+
const execaMock = execa;
|
|
1136
|
+
execaMock.mockImplementation(() => proc);
|
|
1137
|
+
const rt = createClaudeCliRuntime({
|
|
1138
|
+
claudeBin: 'claude',
|
|
1139
|
+
dangerouslySkipPermissions: true,
|
|
1140
|
+
outputFormat: 'stream-json',
|
|
1141
|
+
progressStallTimeoutMs: 5000,
|
|
1142
|
+
// Disable the stall timer so only the progress timer matters.
|
|
1143
|
+
streamStallTimeoutMs: 0,
|
|
1144
|
+
});
|
|
1145
|
+
const events = [];
|
|
1146
|
+
const drainPromise = (async () => {
|
|
1147
|
+
for await (const evt of rt.invoke({ prompt: 'p', model: 'opus', cwd: '/tmp' }))
|
|
1148
|
+
events.push(evt);
|
|
1149
|
+
})();
|
|
1150
|
+
// Push thinking tokens — these should NOT reset the progress timer.
|
|
1151
|
+
const thinkingLine = JSON.stringify({
|
|
1152
|
+
type: 'stream_event',
|
|
1153
|
+
event: { type: 'content_block_delta', index: 0, delta: { type: 'thinking_delta', thinking: 'analyzing...' } },
|
|
1154
|
+
}) + '\n';
|
|
1155
|
+
// Push one thinking delta every second for 6 seconds (past the 5s progress timeout).
|
|
1156
|
+
for (let i = 0; i < 6; i++) {
|
|
1157
|
+
stdout.write(thinkingLine);
|
|
1158
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
1159
|
+
}
|
|
1160
|
+
await vi.advanceTimersByTimeAsync(100);
|
|
1161
|
+
await drainPromise;
|
|
1162
|
+
expect(events.some((e) => e.type === 'error' && e.message.includes('progress stall'))).toBe(true);
|
|
1163
|
+
expect(proc.kill).toHaveBeenCalled();
|
|
1164
|
+
});
|
|
1165
|
+
it('does NOT fire when text_delta events keep arriving', async () => {
|
|
1166
|
+
const { proc, stdout, stderr, resolve } = makeControllableProcess();
|
|
1167
|
+
const execaMock = execa;
|
|
1168
|
+
execaMock.mockImplementation(() => proc);
|
|
1169
|
+
const rt = createClaudeCliRuntime({
|
|
1170
|
+
claudeBin: 'claude',
|
|
1171
|
+
dangerouslySkipPermissions: true,
|
|
1172
|
+
outputFormat: 'stream-json',
|
|
1173
|
+
progressStallTimeoutMs: 5000,
|
|
1174
|
+
streamStallTimeoutMs: 0,
|
|
1175
|
+
});
|
|
1176
|
+
const events = [];
|
|
1177
|
+
const drainPromise = (async () => {
|
|
1178
|
+
for await (const evt of rt.invoke({ prompt: 'p', model: 'opus', cwd: '/tmp' }))
|
|
1179
|
+
events.push(evt);
|
|
1180
|
+
})();
|
|
1181
|
+
// Push text_delta every 3 seconds — progress timer (5s) should never fire.
|
|
1182
|
+
for (let i = 0; i < 3; i++) {
|
|
1183
|
+
const line = JSON.stringify({
|
|
1184
|
+
type: 'stream_event',
|
|
1185
|
+
event: { type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: `chunk${i}` } },
|
|
1186
|
+
}) + '\n';
|
|
1187
|
+
stdout.write(line);
|
|
1188
|
+
await vi.advanceTimersByTimeAsync(3000);
|
|
1189
|
+
}
|
|
1190
|
+
// Should not have stalled.
|
|
1191
|
+
expect(events.some((e) => e.type === 'error' && e.message.includes('progress stall'))).toBe(false);
|
|
1192
|
+
// Clean up.
|
|
1193
|
+
stdout.end();
|
|
1194
|
+
stderr.end();
|
|
1195
|
+
resolve({ exitCode: 0, stdout: '', stderr: '' });
|
|
1196
|
+
await vi.advanceTimersByTimeAsync(100);
|
|
1197
|
+
await drainPromise;
|
|
1198
|
+
});
|
|
1199
|
+
});
|