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,862 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
// We mock execa at the module level so createCodexCliRuntime 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 { createCodexCliRuntime, killActiveCodexSubprocesses } = await import('./codex-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('Codex 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 = createCodexCliRuntime({
|
|
111
|
+
codexBin: 'codex',
|
|
112
|
+
defaultModel: 'gpt-5.3-codex',
|
|
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 = createCodexCliRuntime({
|
|
134
|
+
codexBin: 'codex',
|
|
135
|
+
defaultModel: 'gpt-5.3-codex',
|
|
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 = createCodexCliRuntime({
|
|
153
|
+
codexBin: 'codex',
|
|
154
|
+
defaultModel: 'gpt-5.3-codex',
|
|
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 = createCodexCliRuntime({
|
|
173
|
+
codexBin: 'codex',
|
|
174
|
+
defaultModel: 'gpt-5.3-codex',
|
|
175
|
+
});
|
|
176
|
+
await collectEvents(rt.invoke({
|
|
177
|
+
prompt: 'Hi',
|
|
178
|
+
model: 'gpt-4o',
|
|
179
|
+
cwd: '/tmp',
|
|
180
|
+
}));
|
|
181
|
+
expect(mockExeca).toHaveBeenCalledTimes(1);
|
|
182
|
+
const callArgs = mockExeca.mock.calls[0][1];
|
|
183
|
+
const modelIdx = callArgs.indexOf('-m');
|
|
184
|
+
expect(callArgs[modelIdx + 1]).toBe('gpt-4o');
|
|
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 = createCodexCliRuntime({
|
|
192
|
+
codexBin: 'codex',
|
|
193
|
+
defaultModel: 'gpt-5.3-codex',
|
|
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 = createCodexCliRuntime({
|
|
211
|
+
codexBin: 'codex',
|
|
212
|
+
defaultModel: 'gpt-5.3-codex',
|
|
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('-m');
|
|
222
|
+
expect(callArgs[modelIdx + 1]).toBe('gpt-5.3-codex');
|
|
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 = createCodexCliRuntime({
|
|
231
|
+
codexBin: 'codex',
|
|
232
|
+
defaultModel: 'gpt-5.3-codex',
|
|
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
|
+
expect(callArgs).toContain('--');
|
|
242
|
+
// Should end with `-` (stdin flag) instead of the large prompt text.
|
|
243
|
+
expect(callArgs[callArgs.length - 1]).toBe('-');
|
|
244
|
+
// Should NOT contain the large prompt as a positional arg.
|
|
245
|
+
expect(callArgs).not.toContain(largePrompt);
|
|
246
|
+
// Verify stdin was used.
|
|
247
|
+
const execaOpts = mockExeca.mock.calls[0][2];
|
|
248
|
+
expect(execaOpts.stdin).toBe('pipe');
|
|
249
|
+
});
|
|
250
|
+
it('shutdown cleanup: killActiveCodexSubprocesses kills tracked processes', async () => {
|
|
251
|
+
const sub = createMockSubprocess({
|
|
252
|
+
stdout: '',
|
|
253
|
+
exitCode: 0,
|
|
254
|
+
});
|
|
255
|
+
// Prevent the subprocess from resolving immediately so it stays tracked.
|
|
256
|
+
sub.then = () => ({ catch: () => { } });
|
|
257
|
+
mockExeca.mockReturnValue(sub);
|
|
258
|
+
const rt = createCodexCliRuntime({
|
|
259
|
+
codexBin: 'codex',
|
|
260
|
+
defaultModel: 'gpt-5.3-codex',
|
|
261
|
+
});
|
|
262
|
+
// Start invoke but don't consume fully — just trigger subprocess creation.
|
|
263
|
+
const iter = rt.invoke({
|
|
264
|
+
prompt: 'Hi',
|
|
265
|
+
model: '',
|
|
266
|
+
cwd: '/tmp',
|
|
267
|
+
});
|
|
268
|
+
// Pull one event to start the generator (which adds the subprocess to tracking).
|
|
269
|
+
const iterResult = iter;
|
|
270
|
+
// Actually call .next() to enter the generator body — the subprocess is
|
|
271
|
+
// added to activeSubprocesses inside the generator, before the first yield.
|
|
272
|
+
iterResult.next(); // don't await — we just need it to run up to the first yield/await
|
|
273
|
+
await new Promise(r => setTimeout(r, 10));
|
|
274
|
+
// Kill all tracked subprocesses.
|
|
275
|
+
killActiveCodexSubprocesses();
|
|
276
|
+
expect(sub.kill).toHaveBeenCalledWith('SIGKILL');
|
|
277
|
+
});
|
|
278
|
+
it('runtime has correct id and capabilities', () => {
|
|
279
|
+
const rt = createCodexCliRuntime({
|
|
280
|
+
codexBin: 'codex',
|
|
281
|
+
defaultModel: 'gpt-5.3-codex',
|
|
282
|
+
});
|
|
283
|
+
expect(rt.id).toBe('codex');
|
|
284
|
+
expect(rt.capabilities.has('streaming_text')).toBe(true);
|
|
285
|
+
expect(rt.capabilities.has('tools_fs')).toBe(true);
|
|
286
|
+
expect(rt.capabilities.has('tools_exec')).toBe(true);
|
|
287
|
+
expect(rt.capabilities.has('tools_web')).toBe(true);
|
|
288
|
+
expect(rt.capabilities.has('sessions')).toBe(true);
|
|
289
|
+
});
|
|
290
|
+
it('disableSessions removes sessions capability and forces ephemeral mode', async () => {
|
|
291
|
+
mockExeca.mockReturnValue(createMockSubprocess({
|
|
292
|
+
stdout: 'ok',
|
|
293
|
+
exitCode: 0,
|
|
294
|
+
}));
|
|
295
|
+
const rt = createCodexCliRuntime({
|
|
296
|
+
codexBin: 'codex',
|
|
297
|
+
defaultModel: 'gpt-5.3-codex',
|
|
298
|
+
disableSessions: true,
|
|
299
|
+
});
|
|
300
|
+
expect(rt.capabilities.has('sessions')).toBe(false);
|
|
301
|
+
await collectEvents(rt.invoke({
|
|
302
|
+
prompt: 'Hi',
|
|
303
|
+
model: '',
|
|
304
|
+
cwd: '/tmp',
|
|
305
|
+
sessionKey: 'should-be-ignored',
|
|
306
|
+
}));
|
|
307
|
+
const callArgs = mockExeca.mock.calls[0][1];
|
|
308
|
+
expect(callArgs).toContain('--ephemeral');
|
|
309
|
+
expect(callArgs).not.toContain('--json');
|
|
310
|
+
expect(callArgs).not.toContain('resume');
|
|
311
|
+
});
|
|
312
|
+
it('empty stdout emits done without text_final', async () => {
|
|
313
|
+
mockExeca.mockReturnValue(createMockSubprocess({
|
|
314
|
+
stdout: '',
|
|
315
|
+
exitCode: 0,
|
|
316
|
+
}));
|
|
317
|
+
const rt = createCodexCliRuntime({
|
|
318
|
+
codexBin: 'codex',
|
|
319
|
+
defaultModel: 'gpt-5.3-codex',
|
|
320
|
+
});
|
|
321
|
+
const events = await collectEvents(rt.invoke({
|
|
322
|
+
prompt: 'Hi',
|
|
323
|
+
model: '',
|
|
324
|
+
cwd: '/tmp',
|
|
325
|
+
}));
|
|
326
|
+
// No text_final for empty response.
|
|
327
|
+
expect(events.find((e) => e.type === 'text_final')).toBeUndefined();
|
|
328
|
+
expect(events[events.length - 1].type).toBe('done');
|
|
329
|
+
});
|
|
330
|
+
it('error messages are sanitized: multi-line stderr truncated to first line', async () => {
|
|
331
|
+
mockExeca.mockReturnValue(createMockSubprocess({
|
|
332
|
+
stdout: '',
|
|
333
|
+
stderr: 'auth token expired\nfull prompt: You are a helpful assistant...\nsession: /tmp/codex/abc123',
|
|
334
|
+
exitCode: 1,
|
|
335
|
+
}));
|
|
336
|
+
const rt = createCodexCliRuntime({
|
|
337
|
+
codexBin: 'codex',
|
|
338
|
+
defaultModel: 'gpt-5.3-codex',
|
|
339
|
+
});
|
|
340
|
+
const events = await collectEvents(rt.invoke({
|
|
341
|
+
prompt: 'Say hello',
|
|
342
|
+
model: '',
|
|
343
|
+
cwd: '/tmp',
|
|
344
|
+
}));
|
|
345
|
+
const errorEvt = events.find((e) => e.type === 'error');
|
|
346
|
+
expect(errorEvt).toBeDefined();
|
|
347
|
+
const msg = errorEvt.message;
|
|
348
|
+
// Should contain first line only.
|
|
349
|
+
expect(msg).toContain('auth token expired');
|
|
350
|
+
// Should NOT contain prompt or session content from subsequent lines.
|
|
351
|
+
expect(msg).not.toContain('full prompt');
|
|
352
|
+
expect(msg).not.toContain('session:');
|
|
353
|
+
});
|
|
354
|
+
it('error sanitization skips Codex banner/chatter and surfaces actionable error line', async () => {
|
|
355
|
+
mockExeca.mockReturnValue(createMockSubprocess({
|
|
356
|
+
stdout: '',
|
|
357
|
+
stderr: [
|
|
358
|
+
'OpenAI Codex v0.101.0 (research preview)',
|
|
359
|
+
'--------',
|
|
360
|
+
'workdir: /tmp',
|
|
361
|
+
'model: gpt-5-mini',
|
|
362
|
+
'user',
|
|
363
|
+
'TOP SECRET PROMPT DATA',
|
|
364
|
+
'Reconnecting... 5/5 (stream disconnected before completion)',
|
|
365
|
+
'ERROR: stream disconnected before completion: error sending request for url (https://api.openai.com/v1/responses)',
|
|
366
|
+
].join('\n'),
|
|
367
|
+
exitCode: 1,
|
|
368
|
+
}));
|
|
369
|
+
const rt = createCodexCliRuntime({
|
|
370
|
+
codexBin: 'codex',
|
|
371
|
+
defaultModel: 'gpt-5.3-codex',
|
|
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('stream disconnected before completion');
|
|
382
|
+
expect(msg).not.toContain('OpenAI Codex');
|
|
383
|
+
expect(msg).not.toContain('TOP SECRET');
|
|
384
|
+
});
|
|
385
|
+
it('error sanitization maps rollout path corruption to a clear remediation message', async () => {
|
|
386
|
+
mockExeca.mockReturnValue(createMockSubprocess({
|
|
387
|
+
stdout: '',
|
|
388
|
+
stderr: '2026-02-16T23:36:26.244364Z ERROR codex_core::rollout::list: state db missing rollout path for thread 019c5957-beea-7e92-aca4-42b5c15af63d',
|
|
389
|
+
exitCode: 1,
|
|
390
|
+
}));
|
|
391
|
+
const rt = createCodexCliRuntime({
|
|
392
|
+
codexBin: 'codex',
|
|
393
|
+
defaultModel: 'gpt-5.3-codex',
|
|
394
|
+
});
|
|
395
|
+
const events = await collectEvents(rt.invoke({
|
|
396
|
+
prompt: 'Say hello',
|
|
397
|
+
model: '',
|
|
398
|
+
cwd: '/tmp',
|
|
399
|
+
}));
|
|
400
|
+
const errorEvt = events.find((e) => e.type === 'error');
|
|
401
|
+
expect(errorEvt).toBeDefined();
|
|
402
|
+
const msg = errorEvt.message;
|
|
403
|
+
expect(msg).toContain('codex session state appears corrupted');
|
|
404
|
+
expect(msg).toContain('CODEX_HOME');
|
|
405
|
+
});
|
|
406
|
+
it('ENOENT via tryFinalize: uses fixed message, never leaks prompt', async () => {
|
|
407
|
+
// Simulates execa resolving (reject: false) with failed=true, exitCode=null, code=ENOENT.
|
|
408
|
+
// The real execa shortMessage would contain the full command line including the prompt.
|
|
409
|
+
mockExeca.mockReturnValue(createMockSubprocess({
|
|
410
|
+
stdout: '',
|
|
411
|
+
exitCode: undefined,
|
|
412
|
+
failed: true,
|
|
413
|
+
resultExtra: {
|
|
414
|
+
exitCode: null,
|
|
415
|
+
failed: true,
|
|
416
|
+
code: 'ENOENT',
|
|
417
|
+
originalMessage: 'spawn codex ENOENT',
|
|
418
|
+
shortMessage: "Command failed: codex exec -m gpt-5.3-codex --skip-git-repo-check --ephemeral -s read-only 'TOP SECRET PROMPT DATA'\nspawn codex ENOENT",
|
|
419
|
+
},
|
|
420
|
+
}));
|
|
421
|
+
const rt = createCodexCliRuntime({
|
|
422
|
+
codexBin: 'codex',
|
|
423
|
+
defaultModel: 'gpt-5.3-codex',
|
|
424
|
+
});
|
|
425
|
+
const events = await collectEvents(rt.invoke({
|
|
426
|
+
prompt: 'TOP SECRET PROMPT DATA',
|
|
427
|
+
model: '',
|
|
428
|
+
cwd: '/tmp',
|
|
429
|
+
}));
|
|
430
|
+
const errorEvt = events.find((e) => e.type === 'error');
|
|
431
|
+
expect(errorEvt).toBeDefined();
|
|
432
|
+
const msg = errorEvt.message;
|
|
433
|
+
// Should use the fixed "not found" message.
|
|
434
|
+
expect(msg).toContain('codex binary not found');
|
|
435
|
+
// Must never contain prompt text.
|
|
436
|
+
expect(msg).not.toContain('TOP SECRET');
|
|
437
|
+
expect(msg).not.toContain('Command failed');
|
|
438
|
+
expect(events[events.length - 1].type).toBe('done');
|
|
439
|
+
});
|
|
440
|
+
it('ENOENT via catch handler: uses fixed message, never leaks prompt', async () => {
|
|
441
|
+
// Simulates the .catch() path — execa rejects with an error that includes the command line.
|
|
442
|
+
mockExeca.mockReturnValue(createMockSubprocess({
|
|
443
|
+
stdout: '',
|
|
444
|
+
rejectWith: {
|
|
445
|
+
code: 'ENOENT',
|
|
446
|
+
originalMessage: 'spawn codex ENOENT',
|
|
447
|
+
shortMessage: "Command failed: codex exec -m gpt-5.3-codex --skip-git-repo-check --ephemeral -s read-only 'TOP SECRET PROMPT DATA'\nspawn codex ENOENT",
|
|
448
|
+
message: "Command failed: codex exec -m gpt-5.3-codex --skip-git-repo-check --ephemeral -s read-only 'TOP SECRET PROMPT DATA'\nspawn codex ENOENT",
|
|
449
|
+
},
|
|
450
|
+
}));
|
|
451
|
+
const rt = createCodexCliRuntime({
|
|
452
|
+
codexBin: 'codex',
|
|
453
|
+
defaultModel: 'gpt-5.3-codex',
|
|
454
|
+
});
|
|
455
|
+
const events = await collectEvents(rt.invoke({
|
|
456
|
+
prompt: 'TOP SECRET PROMPT DATA',
|
|
457
|
+
model: '',
|
|
458
|
+
cwd: '/tmp',
|
|
459
|
+
}));
|
|
460
|
+
const errorEvt = events.find((e) => e.type === 'error');
|
|
461
|
+
expect(errorEvt).toBeDefined();
|
|
462
|
+
const msg = errorEvt.message;
|
|
463
|
+
// Should use the fixed "not found" message.
|
|
464
|
+
expect(msg).toContain('codex binary not found');
|
|
465
|
+
// Must never contain prompt text.
|
|
466
|
+
expect(msg).not.toContain('TOP SECRET');
|
|
467
|
+
expect(msg).not.toContain('Command failed');
|
|
468
|
+
expect(events[events.length - 1].type).toBe('done');
|
|
469
|
+
});
|
|
470
|
+
it('non-ENOENT spawn failure via catch handler: generic message, no raw error', async () => {
|
|
471
|
+
// Simulates a non-ENOENT rejection (e.g. EACCES) — should get generic message.
|
|
472
|
+
mockExeca.mockReturnValue(createMockSubprocess({
|
|
473
|
+
stdout: '',
|
|
474
|
+
rejectWith: {
|
|
475
|
+
code: 'EACCES',
|
|
476
|
+
originalMessage: 'spawn codex EACCES',
|
|
477
|
+
shortMessage: "Command failed: codex exec -m gpt-5.3-codex --skip-git-repo-check --ephemeral -s read-only 'secret prompt'\nspawn codex EACCES",
|
|
478
|
+
message: "Command failed: codex exec ...",
|
|
479
|
+
},
|
|
480
|
+
}));
|
|
481
|
+
const rt = createCodexCliRuntime({
|
|
482
|
+
codexBin: 'codex',
|
|
483
|
+
defaultModel: 'gpt-5.3-codex',
|
|
484
|
+
});
|
|
485
|
+
const events = await collectEvents(rt.invoke({
|
|
486
|
+
prompt: 'secret prompt',
|
|
487
|
+
model: '',
|
|
488
|
+
cwd: '/tmp',
|
|
489
|
+
}));
|
|
490
|
+
const errorEvt = events.find((e) => e.type === 'error');
|
|
491
|
+
expect(errorEvt).toBeDefined();
|
|
492
|
+
const msg = errorEvt.message;
|
|
493
|
+
// Should use the generic fixed message with error code.
|
|
494
|
+
expect(msg).toBe('codex process failed unexpectedly (EACCES)');
|
|
495
|
+
// Must never contain prompt text or raw command.
|
|
496
|
+
expect(msg).not.toContain('secret prompt');
|
|
497
|
+
expect(msg).not.toContain('Command failed');
|
|
498
|
+
expect(events[events.length - 1].type).toBe('done');
|
|
499
|
+
});
|
|
500
|
+
it('args include read-only sandbox flag', async () => {
|
|
501
|
+
mockExeca.mockReturnValue(createMockSubprocess({
|
|
502
|
+
stdout: 'ok',
|
|
503
|
+
exitCode: 0,
|
|
504
|
+
}));
|
|
505
|
+
const rt = createCodexCliRuntime({
|
|
506
|
+
codexBin: 'codex',
|
|
507
|
+
defaultModel: 'gpt-5.3-codex',
|
|
508
|
+
});
|
|
509
|
+
await collectEvents(rt.invoke({
|
|
510
|
+
prompt: 'Hi',
|
|
511
|
+
model: '',
|
|
512
|
+
cwd: '/tmp',
|
|
513
|
+
}));
|
|
514
|
+
expect(mockExeca).toHaveBeenCalledTimes(1);
|
|
515
|
+
const callArgs = mockExeca.mock.calls[0][1];
|
|
516
|
+
const sandboxIdx = callArgs.indexOf('-s');
|
|
517
|
+
expect(sandboxIdx).toBeGreaterThan(-1);
|
|
518
|
+
expect(callArgs[sandboxIdx + 1]).toBe('read-only');
|
|
519
|
+
});
|
|
520
|
+
it('dangerous bypass flag replaces read-only sandbox on exec', async () => {
|
|
521
|
+
mockExeca.mockReturnValue(createMockSubprocess({
|
|
522
|
+
stdout: 'ok',
|
|
523
|
+
exitCode: 0,
|
|
524
|
+
}));
|
|
525
|
+
const rt = createCodexCliRuntime({
|
|
526
|
+
codexBin: 'codex',
|
|
527
|
+
defaultModel: 'gpt-5.3-codex',
|
|
528
|
+
dangerouslyBypassApprovalsAndSandbox: true,
|
|
529
|
+
});
|
|
530
|
+
await collectEvents(rt.invoke({
|
|
531
|
+
prompt: 'Hi',
|
|
532
|
+
model: '',
|
|
533
|
+
cwd: '/tmp',
|
|
534
|
+
}));
|
|
535
|
+
expect(mockExeca).toHaveBeenCalledTimes(1);
|
|
536
|
+
const callArgs = mockExeca.mock.calls[0][1];
|
|
537
|
+
expect(callArgs).toContain('--dangerously-bypass-approvals-and-sandbox');
|
|
538
|
+
expect(callArgs).not.toContain('-s');
|
|
539
|
+
expect(callArgs).not.toContain('read-only');
|
|
540
|
+
});
|
|
541
|
+
it('passes --add-dir flags from params.addDirs', async () => {
|
|
542
|
+
mockExeca.mockReturnValue(createMockSubprocess({
|
|
543
|
+
stdout: 'ok',
|
|
544
|
+
exitCode: 0,
|
|
545
|
+
}));
|
|
546
|
+
const rt = createCodexCliRuntime({
|
|
547
|
+
codexBin: 'codex',
|
|
548
|
+
defaultModel: 'gpt-5.3-codex',
|
|
549
|
+
});
|
|
550
|
+
await collectEvents(rt.invoke({
|
|
551
|
+
prompt: 'Hi',
|
|
552
|
+
model: '',
|
|
553
|
+
cwd: '/tmp',
|
|
554
|
+
addDirs: ['/home/user/project', '/home/user/shared'],
|
|
555
|
+
}));
|
|
556
|
+
expect(mockExeca).toHaveBeenCalledTimes(1);
|
|
557
|
+
const callArgs = mockExeca.mock.calls[0][1];
|
|
558
|
+
// Should contain --add-dir /home/user/project --add-dir /home/user/shared
|
|
559
|
+
const firstIdx = callArgs.indexOf('--add-dir');
|
|
560
|
+
expect(firstIdx).toBeGreaterThan(-1);
|
|
561
|
+
expect(callArgs[firstIdx + 1]).toBe('/home/user/project');
|
|
562
|
+
const secondIdx = callArgs.indexOf('--add-dir', firstIdx + 2);
|
|
563
|
+
expect(secondIdx).toBeGreaterThan(-1);
|
|
564
|
+
expect(callArgs[secondIdx + 1]).toBe('/home/user/shared');
|
|
565
|
+
});
|
|
566
|
+
it('omits --add-dir when addDirs is empty', async () => {
|
|
567
|
+
mockExeca.mockReturnValue(createMockSubprocess({
|
|
568
|
+
stdout: 'ok',
|
|
569
|
+
exitCode: 0,
|
|
570
|
+
}));
|
|
571
|
+
const rt = createCodexCliRuntime({
|
|
572
|
+
codexBin: 'codex',
|
|
573
|
+
defaultModel: 'gpt-5.3-codex',
|
|
574
|
+
});
|
|
575
|
+
await collectEvents(rt.invoke({
|
|
576
|
+
prompt: 'Hi',
|
|
577
|
+
model: '',
|
|
578
|
+
cwd: '/tmp',
|
|
579
|
+
addDirs: [],
|
|
580
|
+
}));
|
|
581
|
+
expect(mockExeca).toHaveBeenCalledTimes(1);
|
|
582
|
+
const callArgs = mockExeca.mock.calls[0][1];
|
|
583
|
+
expect(callArgs).not.toContain('--add-dir');
|
|
584
|
+
});
|
|
585
|
+
it('omits --add-dir when addDirs is undefined', async () => {
|
|
586
|
+
mockExeca.mockReturnValue(createMockSubprocess({
|
|
587
|
+
stdout: 'ok',
|
|
588
|
+
exitCode: 0,
|
|
589
|
+
}));
|
|
590
|
+
const rt = createCodexCliRuntime({
|
|
591
|
+
codexBin: 'codex',
|
|
592
|
+
defaultModel: 'gpt-5.3-codex',
|
|
593
|
+
});
|
|
594
|
+
await collectEvents(rt.invoke({
|
|
595
|
+
prompt: 'Hi',
|
|
596
|
+
model: '',
|
|
597
|
+
cwd: '/tmp',
|
|
598
|
+
// addDirs intentionally omitted (undefined)
|
|
599
|
+
}));
|
|
600
|
+
expect(mockExeca).toHaveBeenCalledTimes(1);
|
|
601
|
+
const callArgs = mockExeca.mock.calls[0][1];
|
|
602
|
+
expect(callArgs).not.toContain('--add-dir');
|
|
603
|
+
});
|
|
604
|
+
// --- Session persistence tests ---
|
|
605
|
+
it('sessionKey omits --ephemeral and adds --json', async () => {
|
|
606
|
+
const jsonlOutput = [
|
|
607
|
+
'{"type":"thread.started","thread_id":"abc-123"}',
|
|
608
|
+
'{"type":"item.completed","item":{"type":"agent_message","text":"hello"}}',
|
|
609
|
+
'{"type":"turn.completed","usage":{}}',
|
|
610
|
+
].join('\n') + '\n';
|
|
611
|
+
mockExeca.mockReturnValue(createMockSubprocess({
|
|
612
|
+
stdout: jsonlOutput,
|
|
613
|
+
exitCode: 0,
|
|
614
|
+
}));
|
|
615
|
+
const rt = createCodexCliRuntime({
|
|
616
|
+
codexBin: 'codex',
|
|
617
|
+
defaultModel: 'gpt-5.3-codex',
|
|
618
|
+
});
|
|
619
|
+
const events = await collectEvents(rt.invoke({
|
|
620
|
+
prompt: 'Hi',
|
|
621
|
+
model: '',
|
|
622
|
+
cwd: '/tmp',
|
|
623
|
+
sessionKey: 'test-session-1',
|
|
624
|
+
}));
|
|
625
|
+
expect(mockExeca).toHaveBeenCalledTimes(1);
|
|
626
|
+
const callArgs = mockExeca.mock.calls[0][1];
|
|
627
|
+
// Should NOT have --ephemeral.
|
|
628
|
+
expect(callArgs).not.toContain('--ephemeral');
|
|
629
|
+
// Should have --json.
|
|
630
|
+
expect(callArgs).toContain('--json');
|
|
631
|
+
// Should start with 'exec' (not 'exec resume' on first call).
|
|
632
|
+
expect(callArgs[0]).toBe('exec');
|
|
633
|
+
expect(callArgs[1]).not.toBe('resume');
|
|
634
|
+
// Should extract text from JSONL.
|
|
635
|
+
const final = events.find((e) => e.type === 'text_final');
|
|
636
|
+
expect(final).toBeDefined();
|
|
637
|
+
expect(final.text).toBe('hello');
|
|
638
|
+
});
|
|
639
|
+
it('second call with same sessionKey uses codex exec resume', async () => {
|
|
640
|
+
const jsonlOutput1 = [
|
|
641
|
+
'{"type":"thread.started","thread_id":"thread-uuid-456"}',
|
|
642
|
+
'{"type":"item.completed","item":{"type":"agent_message","text":"first response"}}',
|
|
643
|
+
'{"type":"turn.completed","usage":{}}',
|
|
644
|
+
].join('\n') + '\n';
|
|
645
|
+
const jsonlOutput2 = [
|
|
646
|
+
'{"type":"thread.started","thread_id":"thread-uuid-456"}',
|
|
647
|
+
'{"type":"item.completed","item":{"type":"agent_message","text":"second response"}}',
|
|
648
|
+
'{"type":"turn.completed","usage":{}}',
|
|
649
|
+
].join('\n') + '\n';
|
|
650
|
+
const rt = createCodexCliRuntime({
|
|
651
|
+
codexBin: 'codex',
|
|
652
|
+
defaultModel: 'gpt-5.3-codex',
|
|
653
|
+
});
|
|
654
|
+
// First call — establishes the session.
|
|
655
|
+
mockExeca.mockReturnValue(createMockSubprocess({ stdout: jsonlOutput1, exitCode: 0 }));
|
|
656
|
+
await collectEvents(rt.invoke({
|
|
657
|
+
prompt: 'Round 1',
|
|
658
|
+
model: '',
|
|
659
|
+
cwd: '/tmp',
|
|
660
|
+
sessionKey: 'audit-session',
|
|
661
|
+
}));
|
|
662
|
+
// Second call — should resume.
|
|
663
|
+
mockExeca.mockReturnValue(createMockSubprocess({ stdout: jsonlOutput2, exitCode: 0 }));
|
|
664
|
+
const events2 = await collectEvents(rt.invoke({
|
|
665
|
+
prompt: 'Round 2',
|
|
666
|
+
model: '',
|
|
667
|
+
cwd: '/tmp',
|
|
668
|
+
sessionKey: 'audit-session',
|
|
669
|
+
}));
|
|
670
|
+
expect(mockExeca).toHaveBeenCalledTimes(2);
|
|
671
|
+
const callArgs2 = mockExeca.mock.calls[1][1];
|
|
672
|
+
// Should use 'exec resume <thread_id>'.
|
|
673
|
+
expect(callArgs2[0]).toBe('exec');
|
|
674
|
+
expect(callArgs2[1]).toBe('resume');
|
|
675
|
+
expect(callArgs2[2]).toBe('thread-uuid-456');
|
|
676
|
+
const final2 = events2.find((e) => e.type === 'text_final');
|
|
677
|
+
expect(final2.text).toBe('second response');
|
|
678
|
+
});
|
|
679
|
+
it('dangerous bypass flag is passed on resume calls', async () => {
|
|
680
|
+
const jsonlOutput1 = [
|
|
681
|
+
'{"type":"thread.started","thread_id":"thread-danger-1"}',
|
|
682
|
+
'{"type":"item.completed","item":{"type":"agent_message","text":"first"}}',
|
|
683
|
+
'{"type":"turn.completed","usage":{}}',
|
|
684
|
+
].join('\n') + '\n';
|
|
685
|
+
const jsonlOutput2 = [
|
|
686
|
+
'{"type":"thread.started","thread_id":"thread-danger-1"}',
|
|
687
|
+
'{"type":"item.completed","item":{"type":"agent_message","text":"second"}}',
|
|
688
|
+
'{"type":"turn.completed","usage":{}}',
|
|
689
|
+
].join('\n') + '\n';
|
|
690
|
+
const rt = createCodexCliRuntime({
|
|
691
|
+
codexBin: 'codex',
|
|
692
|
+
defaultModel: 'gpt-5.3-codex',
|
|
693
|
+
dangerouslyBypassApprovalsAndSandbox: true,
|
|
694
|
+
});
|
|
695
|
+
mockExeca.mockReturnValue(createMockSubprocess({ stdout: jsonlOutput1, exitCode: 0 }));
|
|
696
|
+
await collectEvents(rt.invoke({
|
|
697
|
+
prompt: 'Round 1',
|
|
698
|
+
model: '',
|
|
699
|
+
cwd: '/tmp',
|
|
700
|
+
sessionKey: 'danger-session',
|
|
701
|
+
}));
|
|
702
|
+
mockExeca.mockReturnValue(createMockSubprocess({ stdout: jsonlOutput2, exitCode: 0 }));
|
|
703
|
+
await collectEvents(rt.invoke({
|
|
704
|
+
prompt: 'Round 2',
|
|
705
|
+
model: '',
|
|
706
|
+
cwd: '/tmp',
|
|
707
|
+
sessionKey: 'danger-session',
|
|
708
|
+
}));
|
|
709
|
+
const callArgs2 = mockExeca.mock.calls[1][1];
|
|
710
|
+
expect(callArgs2[0]).toBe('exec');
|
|
711
|
+
expect(callArgs2[1]).toBe('resume');
|
|
712
|
+
expect(callArgs2).toContain('--dangerously-bypass-approvals-and-sandbox');
|
|
713
|
+
expect(callArgs2).not.toContain('-s');
|
|
714
|
+
});
|
|
715
|
+
it('without sessionKey still uses --ephemeral (backward compat)', async () => {
|
|
716
|
+
mockExeca.mockReturnValue(createMockSubprocess({
|
|
717
|
+
stdout: 'ok',
|
|
718
|
+
exitCode: 0,
|
|
719
|
+
}));
|
|
720
|
+
const rt = createCodexCliRuntime({
|
|
721
|
+
codexBin: 'codex',
|
|
722
|
+
defaultModel: 'gpt-5.3-codex',
|
|
723
|
+
});
|
|
724
|
+
await collectEvents(rt.invoke({
|
|
725
|
+
prompt: 'Hi',
|
|
726
|
+
model: '',
|
|
727
|
+
cwd: '/tmp',
|
|
728
|
+
// no sessionKey
|
|
729
|
+
}));
|
|
730
|
+
const callArgs = mockExeca.mock.calls[0][1];
|
|
731
|
+
expect(callArgs).toContain('--ephemeral');
|
|
732
|
+
expect(callArgs).not.toContain('--json');
|
|
733
|
+
});
|
|
734
|
+
it('reasoning items emit text_delta but text_final contains only agent_message', async () => {
|
|
735
|
+
const jsonlOutput = [
|
|
736
|
+
'{"type":"thread.started","thread_id":"reason-thread-1"}',
|
|
737
|
+
'{"type":"item.completed","item":{"type":"reasoning","summary":"Let me think step by step..."}}',
|
|
738
|
+
'{"type":"item.completed","item":{"type":"reasoning","text":"Considering the options..."}}',
|
|
739
|
+
'{"type":"item.completed","item":{"type":"agent_message","text":"The answer is 42."}}',
|
|
740
|
+
'{"type":"turn.completed","usage":{}}',
|
|
741
|
+
].join('\n') + '\n';
|
|
742
|
+
mockExeca.mockReturnValue(createMockSubprocess({
|
|
743
|
+
stdout: jsonlOutput,
|
|
744
|
+
exitCode: 0,
|
|
745
|
+
}));
|
|
746
|
+
const rt = createCodexCliRuntime({
|
|
747
|
+
codexBin: 'codex',
|
|
748
|
+
defaultModel: 'gpt-5.3-codex',
|
|
749
|
+
});
|
|
750
|
+
const events = await collectEvents(rt.invoke({
|
|
751
|
+
prompt: 'What is the answer?',
|
|
752
|
+
model: '',
|
|
753
|
+
cwd: '/tmp',
|
|
754
|
+
sessionKey: 'reason-session',
|
|
755
|
+
}));
|
|
756
|
+
// text_delta events should include reasoning text.
|
|
757
|
+
const deltas = events.filter((e) => e.type === 'text_delta');
|
|
758
|
+
const deltaTexts = deltas.map((d) => d.text);
|
|
759
|
+
expect(deltaTexts).toContain('Let me think step by step...');
|
|
760
|
+
expect(deltaTexts).toContain('Considering the options...');
|
|
761
|
+
expect(deltaTexts).toContain('The answer is 42.');
|
|
762
|
+
// text_final should contain only the agent_message, not reasoning text.
|
|
763
|
+
const final = events.find((e) => e.type === 'text_final');
|
|
764
|
+
expect(final).toBeDefined();
|
|
765
|
+
expect(final.text).toBe('The answer is 42.');
|
|
766
|
+
expect(final.text).not.toContain('Let me think');
|
|
767
|
+
expect(final.text).not.toContain('Considering the options');
|
|
768
|
+
});
|
|
769
|
+
it('reasoning item with empty or missing text is silently skipped', async () => {
|
|
770
|
+
const jsonlOutput = [
|
|
771
|
+
'{"type":"thread.started","thread_id":"reason-thread-2"}',
|
|
772
|
+
'{"type":"item.completed","item":{"type":"reasoning","summary":""}}',
|
|
773
|
+
'{"type":"item.completed","item":{"type":"reasoning"}}',
|
|
774
|
+
'{"type":"item.completed","item":{"type":"agent_message","text":"Done."}}',
|
|
775
|
+
'{"type":"turn.completed","usage":{}}',
|
|
776
|
+
].join('\n') + '\n';
|
|
777
|
+
mockExeca.mockReturnValue(createMockSubprocess({
|
|
778
|
+
stdout: jsonlOutput,
|
|
779
|
+
exitCode: 0,
|
|
780
|
+
}));
|
|
781
|
+
const rt = createCodexCliRuntime({
|
|
782
|
+
codexBin: 'codex',
|
|
783
|
+
defaultModel: 'gpt-5.3-codex',
|
|
784
|
+
});
|
|
785
|
+
const events = await collectEvents(rt.invoke({
|
|
786
|
+
prompt: 'Do something',
|
|
787
|
+
model: '',
|
|
788
|
+
cwd: '/tmp',
|
|
789
|
+
sessionKey: 'reason-session-2',
|
|
790
|
+
}));
|
|
791
|
+
// Should not emit empty text_delta events.
|
|
792
|
+
const deltas = events.filter((e) => e.type === 'text_delta');
|
|
793
|
+
const emptyDeltas = deltas.filter((d) => !d.text);
|
|
794
|
+
expect(emptyDeltas).toHaveLength(0);
|
|
795
|
+
// Only the agent_message delta should be present.
|
|
796
|
+
const deltaTexts = deltas.map((d) => d.text);
|
|
797
|
+
expect(deltaTexts).toEqual(['Done.']);
|
|
798
|
+
// Streaming continues to completion.
|
|
799
|
+
expect(events[events.length - 1].type).toBe('done');
|
|
800
|
+
});
|
|
801
|
+
it('reasoning item with non-string text field is silently skipped and streaming continues', async () => {
|
|
802
|
+
const jsonlOutput = [
|
|
803
|
+
'{"type":"thread.started","thread_id":"reason-thread-3"}',
|
|
804
|
+
'{"type":"item.completed","item":{"type":"reasoning","text":42}}',
|
|
805
|
+
'{"type":"item.completed","item":{"type":"reasoning","text":{"nested":"object"}}}',
|
|
806
|
+
'{"type":"item.completed","item":{"type":"agent_message","text":"Still works."}}',
|
|
807
|
+
'{"type":"turn.completed","usage":{}}',
|
|
808
|
+
].join('\n') + '\n';
|
|
809
|
+
mockExeca.mockReturnValue(createMockSubprocess({
|
|
810
|
+
stdout: jsonlOutput,
|
|
811
|
+
exitCode: 0,
|
|
812
|
+
}));
|
|
813
|
+
const rt = createCodexCliRuntime({
|
|
814
|
+
codexBin: 'codex',
|
|
815
|
+
defaultModel: 'gpt-5.3-codex',
|
|
816
|
+
});
|
|
817
|
+
const events = await collectEvents(rt.invoke({
|
|
818
|
+
prompt: 'Do something',
|
|
819
|
+
model: '',
|
|
820
|
+
cwd: '/tmp',
|
|
821
|
+
sessionKey: 'reason-session-3',
|
|
822
|
+
}));
|
|
823
|
+
// Non-string text fields should produce no text_delta.
|
|
824
|
+
const deltas = events.filter((e) => e.type === 'text_delta');
|
|
825
|
+
const deltaTexts = deltas.map((d) => d.text);
|
|
826
|
+
expect(deltaTexts).toEqual(['Still works.']);
|
|
827
|
+
// No error events — streaming continues without error.
|
|
828
|
+
expect(events.find((e) => e.type === 'error')).toBeUndefined();
|
|
829
|
+
expect(events[events.length - 1].type).toBe('done');
|
|
830
|
+
});
|
|
831
|
+
it('different sessionKeys get independent sessions', async () => {
|
|
832
|
+
const jsonlA = [
|
|
833
|
+
'{"type":"thread.started","thread_id":"thread-aaa"}',
|
|
834
|
+
'{"type":"item.completed","item":{"type":"agent_message","text":"a"}}',
|
|
835
|
+
'{"type":"turn.completed","usage":{}}',
|
|
836
|
+
].join('\n') + '\n';
|
|
837
|
+
const jsonlB = [
|
|
838
|
+
'{"type":"thread.started","thread_id":"thread-bbb"}',
|
|
839
|
+
'{"type":"item.completed","item":{"type":"agent_message","text":"b"}}',
|
|
840
|
+
'{"type":"turn.completed","usage":{}}',
|
|
841
|
+
].join('\n') + '\n';
|
|
842
|
+
const jsonlA2 = [
|
|
843
|
+
'{"type":"thread.started","thread_id":"thread-aaa"}',
|
|
844
|
+
'{"type":"item.completed","item":{"type":"agent_message","text":"a2"}}',
|
|
845
|
+
'{"type":"turn.completed","usage":{}}',
|
|
846
|
+
].join('\n') + '\n';
|
|
847
|
+
const rt = createCodexCliRuntime({
|
|
848
|
+
codexBin: 'codex',
|
|
849
|
+
defaultModel: 'gpt-5.3-codex',
|
|
850
|
+
});
|
|
851
|
+
mockExeca.mockReturnValue(createMockSubprocess({ stdout: jsonlA, exitCode: 0 }));
|
|
852
|
+
await collectEvents(rt.invoke({ prompt: 'a1', model: '', cwd: '/tmp', sessionKey: 'session-a' }));
|
|
853
|
+
mockExeca.mockReturnValue(createMockSubprocess({ stdout: jsonlB, exitCode: 0 }));
|
|
854
|
+
await collectEvents(rt.invoke({ prompt: 'b1', model: '', cwd: '/tmp', sessionKey: 'session-b' }));
|
|
855
|
+
mockExeca.mockReturnValue(createMockSubprocess({ stdout: jsonlA2, exitCode: 0 }));
|
|
856
|
+
await collectEvents(rt.invoke({ prompt: 'a2', model: '', cwd: '/tmp', sessionKey: 'session-a' }));
|
|
857
|
+
// Third call should resume session-a's thread, not session-b's.
|
|
858
|
+
const callArgs3 = mockExeca.mock.calls[2][1];
|
|
859
|
+
expect(callArgs3[1]).toBe('resume');
|
|
860
|
+
expect(callArgs3[2]).toBe('thread-aaa');
|
|
861
|
+
});
|
|
862
|
+
});
|