carthagent 0.0.0-stage → 1.0.7
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/LICENSE +21 -0
- package/README.md +219 -2
- package/bin/ctg.mjs +736 -0
- package/docs/budgets.md +72 -0
- package/docs/compaction.md +34 -0
- package/docs/context-efficiency-plan.md +24 -0
- package/docs/context-efficiency-review.md +27 -0
- package/docs/context-efficiency.md +49 -0
- package/docs/deepseek-jev-benchmark-plan.md +70 -0
- package/docs/deepseek-jev-benchmark-repair-plan.md +20 -0
- package/docs/deepseek-jev-benchmark-restart.md +9 -0
- package/docs/deepseek-jev-benchmark-results.md +77 -0
- package/docs/deepseek-jev-benchmark-running.md +60 -0
- package/docs/deepseek-jev-benchmark-v3-results.md +41 -0
- package/docs/deepseek-jev-benchmark-v4-protocol.md +15 -0
- package/docs/deepseek-jev-benchmark-v4-results.md +119 -0
- package/docs/deepseek-jev-http400-diagnosis.md +45 -0
- package/docs/delivery-review-evidence.md +53 -0
- package/docs/delivery-timeout-repair-plan.md +51 -0
- package/docs/evaluation-readiness.md +31 -0
- package/docs/evaluation.md +70 -0
- package/docs/extending.md +354 -0
- package/docs/glm-pilot-execution.md +9 -0
- package/docs/glm-pilot-plan.md +15 -0
- package/docs/glm-pilot-results.md +71 -0
- package/docs/graph-context-optimization.md +15 -0
- package/docs/jev-repository-pilot-plan.md +25 -0
- package/docs/jev-repository-pilot-protocol.md +39 -0
- package/docs/jev-repository-pilot-restart.md +7 -0
- package/docs/jev-repository-pilot-results.md +79 -0
- package/docs/jev.md +32 -0
- package/docs/model-registry.md +25 -0
- package/docs/native-compatibility.md +117 -0
- package/docs/opencode-archive.md +9 -0
- package/docs/opencode-migration.md +115 -0
- package/docs/opencode-runtime.md +94 -0
- package/docs/opencode-workspace.md +31 -0
- package/docs/owned-runtime-workflow-registration.md +17 -0
- package/docs/plan-advisor-evaluation.md +38 -0
- package/docs/planning-startup-fix-plan.md +23 -0
- package/docs/planning-startup-fix-review.md +24 -0
- package/docs/planning.md +118 -0
- package/docs/production-merge-review.md +23 -0
- package/docs/quality.md +35 -0
- package/docs/repository-benchmark-design.md +111 -0
- package/docs/review-assurance.md +9 -0
- package/docs/review-experiment-results.md +59 -0
- package/docs/tested-planning-roadmap.md +58 -0
- package/docs/tested-planning.md +58 -0
- package/docs/tui-pickers.md +9 -0
- package/docs/webdriver.md +83 -0
- package/docs/workflow-modes.md +26 -0
- package/docs/workflow.d2 +17 -0
- package/docs/workflow.svg +149 -0
- package/docs/workspace-coordination.md +9 -0
- package/evaluate.mjs +249 -0
- package/extensions/codegraph.ts +36 -0
- package/extensions/delivery.ts +819 -0
- package/extensions/experiential.ts +21 -0
- package/extensions/subscriptions.ts +21 -0
- package/guidance/profiles/authenticated-web.json +24 -0
- package/guidance/profiles/data-visualization.json +25 -0
- package/guidance/profiles/database-change.json +24 -0
- package/guidance/profiles/external-api.json +24 -0
- package/guidance/profiles/transactional-data.json +25 -0
- package/guidance/profiles/web-application.json +26 -0
- package/lib/acceptance-review.mjs +4 -0
- package/lib/browser-bin.mjs +160 -0
- package/lib/browser-errors.mjs +54 -0
- package/lib/browser.mjs +219 -0
- package/lib/budget.mjs +37 -0
- package/lib/cdp.mjs +221 -0
- package/lib/cloud/client.mjs +275 -0
- package/lib/cloud/control-plane.mjs +350 -0
- package/lib/cloud/gateway.mjs +380 -0
- package/lib/cloud/http.mjs +135 -0
- package/lib/cloud/ledger.mjs +215 -0
- package/lib/codegraph-query.mjs +60 -0
- package/lib/codegraph.mjs +378 -0
- package/lib/context-archive.mjs +18 -0
- package/lib/delivery-context.mjs +132 -0
- package/lib/delivery-guidance.mjs +9 -0
- package/lib/delivery-review.mjs +94 -0
- package/lib/delivery.mjs +610 -0
- package/lib/engine.mjs +137 -0
- package/lib/guidance.mjs +71 -0
- package/lib/jev-advisor.mjs +47 -0
- package/lib/opencode/archive.mjs +122 -0
- package/lib/opencode/compatibility.mjs +19 -0
- package/lib/opencode/credentials.mjs +205 -0
- package/lib/opencode/evaluation-budget-plugin/index.ts +147 -0
- package/lib/opencode/evaluation.mjs +242 -0
- package/lib/opencode/file-lock.mjs +86 -0
- package/lib/opencode/headless.mjs +200 -0
- package/lib/opencode/review.mjs +213 -0
- package/lib/opencode/runtime.mjs +246 -0
- package/lib/opencode/service.mjs +249 -0
- package/lib/opencode/session-options.mjs +60 -0
- package/lib/opencode/workspace.mjs +133 -0
- package/lib/openrouter-jev.mjs +37 -0
- package/lib/outcome-contract.mjs +21 -0
- package/lib/plan-advisor.mjs +38 -0
- package/lib/planning-access.mjs +7 -0
- package/lib/planning-advice.mjs +28 -0
- package/lib/planning-discovery.mjs +14 -0
- package/lib/planning-guide.mjs +34 -0
- package/lib/planning-view.mjs +57 -0
- package/lib/planning.mjs +155 -0
- package/lib/probe-diagnostics.mjs +13 -0
- package/lib/providers/experiential.mjs +292 -0
- package/lib/providers/model-catalog.json +700 -0
- package/lib/providers/model-catalog.mjs +7 -0
- package/lib/providers/names.mjs +13 -0
- package/lib/providers/subscription-models.mjs +483 -0
- package/lib/reports.mjs +80 -0
- package/lib/review-state.mjs +49 -0
- package/lib/review.mjs +114 -0
- package/lib/scenarios.mjs +99 -0
- package/lib/ship-auth.mjs +44 -0
- package/lib/ship-jev.mjs +59 -0
- package/lib/ship-telemetry.mjs +183 -0
- package/lib/tui/ansi.mjs +255 -0
- package/lib/tui/app.mjs +2445 -0
- package/lib/tui/auth.mjs +249 -0
- package/lib/tui/carthage.mjs +109 -0
- package/lib/tui/clipboard.mjs +122 -0
- package/lib/tui/d2.mjs +431 -0
- package/lib/tui/feed.mjs +467 -0
- package/lib/tui/framing.mjs +80 -0
- package/lib/tui/glyphs.mjs +172 -0
- package/lib/tui/hash-worker.mjs +11 -0
- package/lib/tui/mock-engine.mjs +222 -0
- package/lib/tui/models.mjs +73 -0
- package/lib/tui/picker.mjs +18 -0
- package/lib/tui/theme.mjs +663 -0
- package/lib/tui/title.mjs +7 -0
- package/lib/update-channel.mjs +62 -0
- package/lib/update.mjs +230 -0
- package/lib/webdriver.mjs +82 -0
- package/lib/workflow.mjs +88 -0
- package/lib/workspace.mjs +39 -0
- package/metadata.json +8 -0
- package/package.json +94 -4
- package/plugins/carthagent/index.ts +3 -0
- package/plugins/carthagent/package.json +28 -0
- package/plugins/carthagent/src/archive-rpc.ts +46 -0
- package/plugins/carthagent/src/bootstrap.ts +38 -0
- package/plugins/carthagent/src/index.ts +62 -0
- package/plugins/carthagent/src/pull-request-rpc.ts +54 -0
- package/plugins/carthagent/src/rpc.ts +76 -0
- package/plugins/carthagent/src/server/archive-rpc.ts +327 -0
- package/plugins/carthagent/src/server/budgets.ts +21 -0
- package/plugins/carthagent/src/server/codegraph.ts +46 -0
- package/plugins/carthagent/src/server/delivery.ts +488 -0
- package/plugins/carthagent/src/server/device-auth.ts +91 -0
- package/plugins/carthagent/src/server/native-delivery.ts +26 -0
- package/plugins/carthagent/src/server/orchestration.ts +56 -0
- package/plugins/carthagent/src/server/policy.ts +24 -0
- package/plugins/carthagent/src/server/providers.ts +424 -0
- package/plugins/carthagent/src/server/pull-request-rpc.ts +387 -0
- package/plugins/carthagent/src/server/session-controls.ts +82 -0
- package/plugins/carthagent/src/server/telemetry.ts +92 -0
- package/plugins/carthagent/src/server/workspace-rpc.ts +349 -0
- package/plugins/carthagent/src/session-rpc.ts +31 -0
- package/plugins/carthagent/src/testing.ts +85 -0
- package/plugins/carthagent/src/workspace-rpc.ts +133 -0
- package/plugins/carthagent/test-plugin/index.ts +4 -0
- package/plugins/carthagent/test-plugin/package.json +1 -0
- package/plugins/carthagent/tsconfig.json +19 -0
- package/plugins/carthagent/tui.ts +1 -0
- package/prompts/delivery.md +7 -0
- package/public/index.html +983 -0
- package/scenarios/cli/grade_cli.py +69 -0
- package/scenarios/cli/scenario.json +21 -0
- package/scenarios/game/baseline/minecraft.py +193 -0
- package/scenarios/game/context.md +1 -0
- package/scenarios/game/grade_minecraft_clone.py +603 -0
- package/scenarios/game/requirements.txt +2 -0
- package/scenarios/game/scenario.json +33 -0
- package/server.mjs +341 -0
- package/skills/game-development/SKILL.md +36 -0
- package/skills/game-development/assets/ursina_starter.py +98 -0
- package/skills/game-development/scripts/verify_ursina.py +80 -0
- package/skills/software-delivery/SKILL.md +50 -0
- package/skills/typescript-delivery/SKILL.md +69 -0
- package/tests/acceptance-review.test.mjs +13 -0
- package/tests/auth.test.mjs +234 -0
- package/tests/bounded-summary.test.mjs +68 -0
- package/tests/browser-bin.test.mjs +91 -0
- package/tests/browser.test.mjs +96 -0
- package/tests/budget-engine.test.mjs +110 -0
- package/tests/budget.test.mjs +23 -0
- package/tests/cli.test.mjs +245 -0
- package/tests/clipboard.test.mjs +79 -0
- package/tests/cloud-client.test.mjs +129 -0
- package/tests/cloud-control-plane.test.mjs +144 -0
- package/tests/cloud-gateway.test.mjs +147 -0
- package/tests/cloud-http.test.mjs +102 -0
- package/tests/cloud-ledger.test.mjs +75 -0
- package/tests/codegraph-query.test.mjs +66 -0
- package/tests/codegraph.test.mjs +142 -0
- package/tests/delivery-context.test.mjs +66 -0
- package/tests/delivery-review.test.mjs +139 -0
- package/tests/delivery.test.mjs +343 -0
- package/tests/engine-api.mjs +24 -0
- package/tests/experiential-extension.test.mjs +37 -0
- package/tests/experiential.test.mjs +300 -0
- package/tests/extension.test.mjs +921 -0
- package/tests/fixtures/archive-plugin/index.ts +10 -0
- package/tests/guidance.test.mjs +78 -0
- package/tests/model-registry.test.mjs +68 -0
- package/tests/models.test.mjs +70 -0
- package/tests/openai-idempotency.test.mjs +99 -0
- package/tests/opencode-archive-rpc.test.mjs +82 -0
- package/tests/opencode-delivery.test.mjs +231 -0
- package/tests/opencode-providers.test.mjs +414 -0
- package/tests/opencode-runtime.test.mjs +455 -0
- package/tests/opencode-session-controls.test.mjs +115 -0
- package/tests/opencode-session-options.test.mjs +26 -0
- package/tests/opencode-workspace.test.mjs +339 -0
- package/tests/outcome-contract.test.mjs +17 -0
- package/tests/picker.test.mjs +34 -0
- package/tests/plan-advisor.test.mjs +88 -0
- package/tests/plan-review-loop.test.mjs +53 -0
- package/tests/planning-advice.test.mjs +27 -0
- package/tests/planning-contract.test.mjs +65 -0
- package/tests/planning-startup.test.mjs +38 -0
- package/tests/planning-view.test.mjs +33 -0
- package/tests/planning.test.mjs +223 -0
- package/tests/probe-diagnostics.test.mjs +21 -0
- package/tests/pull-request-rpc.test.mjs +366 -0
- package/tests/review-state.test.mjs +32 -0
- package/tests/review.test.mjs +127 -0
- package/tests/server.test.mjs +252 -0
- package/tests/ship-jev.test.mjs +77 -0
- package/tests/ship-telemetry.test.mjs +333 -0
- package/tests/standard-workflow.test.mjs +28 -0
- package/tests/subscription-models.test.mjs +416 -0
- package/tests/subscriptions-extension.test.mjs +27 -0
- package/tests/terminal-title.test.mjs +13 -0
- package/tests/test_grade_minecraft_clone.py +199 -0
- package/tests/test_starter.py +78 -0
- package/tests/theme.test.mjs +175 -0
- package/tests/tui-copy-paste.test.mjs +54 -0
- package/tests/tui.test.mjs +808 -0
- package/tests/update-channels.test.mjs +129 -0
- package/tests/update.test.mjs +175 -0
- package/tests/webdriver.test.mjs +31 -0
- package/tests/workflow.test.mjs +47 -0
- package/tests/workspace-rpc.test.mjs +236 -0
- package/tests/workspace.test.mjs +24 -0
- package/tools/benchmark/advisor.test.mjs +7 -0
- package/tools/benchmark/advisor.ts +27 -0
- package/tools/benchmark/campaign.mjs +45 -0
- package/tools/benchmark/diagnose-request.mjs +40 -0
- package/tools/benchmark/eval-common.mjs +32 -0
- package/tools/benchmark/eval-ledger.mjs +22 -0
- package/tools/benchmark/eval-shop.mjs +26 -0
- package/tools/benchmark/eval-voxel.mjs +20 -0
- package/tools/benchmark/evaluate.mjs +24 -0
- package/tools/benchmark/evaluators.test.mjs +13 -0
- package/tools/benchmark/fixtures/shop/server.mjs +18 -0
- package/tools/benchmark/fixtures/shop/ui.html +8 -0
- package/tools/benchmark/freeze.mjs +30 -0
- package/tools/benchmark/gateway.mjs +91 -0
- package/tools/benchmark/gateway.test.mjs +38 -0
- package/tools/benchmark/generator.mjs +11 -0
- package/tools/benchmark/generator.test.mjs +26 -0
- package/tools/benchmark/guard.test.mjs +133 -0
- package/tools/benchmark/guard.ts +136 -0
- package/tools/benchmark/native-plugin/index.ts +24 -0
- package/tools/benchmark/planning-startup.test.mjs +47 -0
- package/tools/benchmark/preflight-live.mjs +31 -0
- package/tools/benchmark/preflight.mjs +22 -0
- package/tools/benchmark/replay-diagnostic.mjs +23 -0
- package/tools/benchmark/runner.mjs +141 -0
- package/tools/benchmark/runner.test.mjs +93 -0
- package/tools/benchmark/runtime.mjs +23 -0
- package/tools/benchmark/selftest.mjs +47 -0
- package/tools/benchmark/smoke-jev.mjs +5 -0
- package/tools/benchmark/tasks.mjs +20 -0
- package/tools/benchmark/transport.mjs +65 -0
- package/tools/benchmark/transport.test.mjs +44 -0
- package/tools/benchmark/upstream-error.mjs +18 -0
- package/tools/benchmark/upstream-error.test.mjs +10 -0
- package/tools/browser-check.mjs +109 -0
- package/tools/check-syntax.mjs +27 -0
- package/tools/evaluate-plan-advisor.mjs +20 -0
- package/tools/native-fixture.mjs +194 -0
- package/tools/native-pty-smoke.py +108 -0
- package/tools/native-report-plugin/index.ts +50 -0
- package/tools/native-report-plugin/package.json +7 -0
- package/tools/patch-compaction.mjs +24 -0
- package/tools/patch-model-catalog.mjs +12 -0
- package/tools/patch-print-shutdown.mjs +39 -0
- package/tools/repo-pilot/evaluator.cjs +32 -0
- package/tools/repo-pilot/reference.mjs +43 -0
- package/tools/repo-pilot/runner.mjs +85 -0
- package/tools/repo-pilot/sandbox-entry.mjs +12 -0
- package/tools/repo-pilot/tasks.mjs +20 -0
- package/tools/repo-pilot/type-evaluator.cjs +6 -0
- package/tools/test-native-controls.mjs +84 -0
- package/tools/test-native-delivery-host.mjs +307 -0
- package/tools/test-native-evaluation.mjs +46 -0
- package/tools/test-native-host.mjs +385 -0
- package/tools/test-native-outcomes.mjs +73 -0
- package/tools/test-native-package.mjs +70 -0
- package/tools/test-native-pty.mjs +20 -0
- package/tools/test-native-report.mjs +88 -0
- package/tools/test-native-swe-host.mjs +132 -0
- package/tools/test-native-swe.mjs +122 -0
- package/tools/test-picker-pty.py +45 -0
- package/tools/test-python.mjs +29 -0
- package/tools/test-webdriver.mjs +54 -0
- package/tools/verify-release-version.mjs +37 -0
- package/tools/webdriver-check.mjs +52 -0
- package/vendor/agent/bounded-summary.mjs +88 -0
- package/vendor/agent/chunks/anthropic-messages-VWZZOSJQ.js +6 -0
- package/vendor/agent/chunks/anthropic.js +75 -0
- package/vendor/agent/chunks/azure-openai-responses-BJLAM6CD.js +2 -0
- package/vendor/agent/chunks/bedrock-converse-stream.js +74 -0
- package/vendor/agent/chunks/chord-context.js +96 -0
- package/vendor/agent/chunks/chunk-4L3WN2XY.js +2 -0
- package/vendor/agent/chunks/chunk-6CYCI3EJ.js +12 -0
- package/vendor/agent/chunks/chunk-6R7UI4IY.js +2 -0
- package/vendor/agent/chunks/chunk-7ZC4UXSL.js +2 -0
- package/vendor/agent/chunks/chunk-AXIIZGTV.js +2 -0
- package/vendor/agent/chunks/chunk-CLNPYIDP.js +42 -0
- package/vendor/agent/chunks/chunk-DRQDKNXA.js +2 -0
- package/vendor/agent/chunks/chunk-GMWCTUPB.js +2 -0
- package/vendor/agent/chunks/chunk-IDDQWTHI.js +2 -0
- package/vendor/agent/chunks/chunk-JVUZSMYM.js +1548 -0
- package/vendor/agent/chunks/chunk-L2ZPNLYD.js +2 -0
- package/vendor/agent/chunks/chunk-NUHFSC37.js +25 -0
- package/vendor/agent/chunks/chunk-OUXLLA64.js +11 -0
- package/vendor/agent/chunks/chunk-PGXDEOVV.js +2 -0
- package/vendor/agent/chunks/chunk-PJACZCIR.js +2 -0
- package/vendor/agent/chunks/chunk-PRRNXB7S.js +2 -0
- package/vendor/agent/chunks/chunk-TEPMHNKQ.js +2 -0
- package/vendor/agent/chunks/chunk-TRDNDS6A.js +4 -0
- package/vendor/agent/chunks/chunk-U6ZMQKGD.js +50 -0
- package/vendor/agent/chunks/chunk-UAQELI3K.js +2 -0
- package/vendor/agent/chunks/chunk-USJZMAIR.js +2 -0
- package/vendor/agent/chunks/chunk-XDUWOHPL.js +6 -0
- package/vendor/agent/chunks/chunk-XNGRGP62.js +2 -0
- package/vendor/agent/chunks/github-copilot.js +2 -0
- package/vendor/agent/chunks/google-generative-ai-Q2K7FBDI.js +2 -0
- package/vendor/agent/chunks/google-vertex-7UJBF4S7.js +2 -0
- package/vendor/agent/chunks/https-proxy-agent-2VXB7436.js +13 -0
- package/vendor/agent/chunks/image-resize-worker.js +2 -0
- package/vendor/agent/chunks/kimi-coding.js +2 -0
- package/vendor/agent/chunks/lib-HGQMNYVT.js +9 -0
- package/vendor/agent/chunks/mistral-conversations-4M6BZBX6.js +5 -0
- package/vendor/agent/chunks/multipart-parser-SQ6GDJL7.js +3 -0
- package/vendor/agent/chunks/node-KG362ECQ.js +16 -0
- package/vendor/agent/chunks/node-TWVVAGPO.js +2 -0
- package/vendor/agent/chunks/openai-codex-responses-R6VYZTVY.js +10 -0
- package/vendor/agent/chunks/openai-codex.js +75 -0
- package/vendor/agent/chunks/openai-completions-EKZT2IH2.js +7 -0
- package/vendor/agent/chunks/openai-responses-TFDINO6W.js +2 -0
- package/vendor/agent/chunks/openrouter-images-N4XVVFSD.js +2 -0
- package/vendor/agent/chunks/openrouter.js +75 -0
- package/vendor/agent/chunks/pi-messages-PML4SPVJ.js +8 -0
- package/vendor/agent/chunks/radius.js +75 -0
- package/vendor/agent/chunks/src-UK77SYZE.js +4 -0
- package/vendor/agent/chunks/xai.js +2 -0
- package/vendor/agent/cli.js +3 -0
- package/vendor/agent/dist/modes/interactive/theme/dark.json +91 -0
- package/vendor/agent/dist/modes/interactive/theme/light.json +90 -0
- package/vendor/agent/dist/modes/interactive/theme/theme-schema.json +356 -0
- package/vendor/agent/index.js +2 -0
- package/vendor/agent/modes/interactive/theme/dark.json +91 -0
- package/vendor/agent/modes/interactive/theme/light.json +90 -0
- package/vendor/agent/modes/interactive/theme/theme-schema.json +356 -0
- package/vendor/agent/package.json +14 -0
- package/vendor/agent/rpc-entry.js +3 -0
- package/vendor/agent/theme/dark.json +91 -0
- package/vendor/agent/theme/light.json +90 -0
- package/vendor/agent/theme/theme-schema.json +356 -0
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { getEventListeners } from 'node:events';
|
|
6
|
+
import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises';
|
|
7
|
+
import { spawn } from 'node:child_process';
|
|
8
|
+
import { nativeCommandConfig } from '../lib/opencode/compatibility.mjs';
|
|
9
|
+
import { provisionRuntime, resolveOpenCode, runtimeEnvironment, runtimePaths, withFileLock, workspaceEnvironment } from '../lib/opencode/runtime.mjs';
|
|
10
|
+
import { cancelSession, createSession, ensureService, getSession, launchTui, promptSession, serviceRequest, sessionMessages, setSessionEnvironment, stopService } from '../lib/opencode/service.mjs';
|
|
11
|
+
import { runHeadless } from '../lib/opencode/headless.mjs';
|
|
12
|
+
|
|
13
|
+
function pendingUntilAbort(signal) {
|
|
14
|
+
return new Promise((_, reject) => {
|
|
15
|
+
const keepAlive = setTimeout(() => {}, 5000);
|
|
16
|
+
const aborted = () => {
|
|
17
|
+
clearTimeout(keepAlive);
|
|
18
|
+
reject(new DOMException('Aborted', 'AbortError'));
|
|
19
|
+
};
|
|
20
|
+
signal.addEventListener('abort', aborted, { once: true });
|
|
21
|
+
if (signal.aborted) aborted();
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
test('CTG runtime paths and env are isolated from host OpenCode roots', () => {
|
|
26
|
+
const root = path.join(os.tmpdir(), 'ctg-test-profile');
|
|
27
|
+
const paths = runtimePaths({ root });
|
|
28
|
+
const env = runtimeEnvironment({ root, env: { ANTHROPIC_API_KEY: 'test' } });
|
|
29
|
+
assert.equal(env.XDG_CONFIG_HOME, paths.config);
|
|
30
|
+
assert.equal(env.XDG_DATA_HOME, paths.data);
|
|
31
|
+
assert.equal(env.XDG_STATE_HOME, paths.state);
|
|
32
|
+
assert.equal(env.XDG_CACHE_HOME, paths.cache);
|
|
33
|
+
assert.equal(env.HOME, paths.home);
|
|
34
|
+
assert.equal(env.APPDATA, path.join(paths.home, 'AppData', 'Roaming'));
|
|
35
|
+
assert.equal(env.LOCALAPPDATA, path.join(paths.home, 'AppData', 'Local'));
|
|
36
|
+
assert.equal(env.OPENCODE_TEST_HOME, paths.home);
|
|
37
|
+
assert.equal(env.OPENCODE_CONFIG_DIR, paths.configDir);
|
|
38
|
+
assert.equal(env.OPENCODE_CONFIG_PROJECT_DISABLE, 'true');
|
|
39
|
+
assert.equal(env.OPENCODE_DB, path.join(paths.data, 'opencode.db'));
|
|
40
|
+
assert.equal(env.ANTHROPIC_API_KEY, 'test');
|
|
41
|
+
assert.equal(env.OPENCODE_CUSTOM_CONFIG, undefined);
|
|
42
|
+
assert.notEqual(paths.serviceFile, path.join(os.homedir(), '.local/state/opencode/service.json'));
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('runtime removes inherited OpenCode profile overrides', () => {
|
|
46
|
+
const root = path.resolve('/tmp/ctg-isolated');
|
|
47
|
+
const paths = runtimePaths({ root });
|
|
48
|
+
const env = runtimeEnvironment({ root, env: {
|
|
49
|
+
OPENCODE_CONFIG_DIR: '/user/config',
|
|
50
|
+
OPENCODE_CONFIG: '/user/opencode.json',
|
|
51
|
+
OPENCODE_CONFIG_PROJECT_DISABLE: 'false',
|
|
52
|
+
OPENCODE_SERVER_PASSWORD: 'user-service-secret',
|
|
53
|
+
OPENAI_API_KEY: 'preserved',
|
|
54
|
+
} });
|
|
55
|
+
assert.equal(env.OPENCODE_CONFIG_DIR, paths.configDir);
|
|
56
|
+
assert.equal(env.OPENCODE_CONFIG, paths.configFile);
|
|
57
|
+
assert.equal(env.OPENCODE_CONFIG_PROJECT_DISABLE, 'true');
|
|
58
|
+
assert.equal(env.OPENCODE_SERVER_PASSWORD, undefined);
|
|
59
|
+
assert.equal(env.OPENAI_API_KEY, 'preserved');
|
|
60
|
+
assert.equal(env.APPDATA, paths.appData);
|
|
61
|
+
assert.equal(env.LOCALAPPDATA, paths.localAppData);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('runtime overrides inherited Windows profile roots with CTG-owned paths', () => {
|
|
65
|
+
const root = path.resolve('/tmp/ctg-windows-roots');
|
|
66
|
+
const paths = runtimePaths({ root });
|
|
67
|
+
const env = runtimeEnvironment({ root, env: {
|
|
68
|
+
APPDATA: 'C:\\Users\\foreign\\AppData\\Roaming',
|
|
69
|
+
LOCALAPPDATA: 'C:\\Users\\foreign\\AppData\\Local',
|
|
70
|
+
OPENCODE_CONFIG_PROJECT_DISABLE: 'false',
|
|
71
|
+
} });
|
|
72
|
+
assert.equal(env.APPDATA, paths.appData);
|
|
73
|
+
assert.equal(env.LOCALAPPDATA, paths.localAppData);
|
|
74
|
+
assert.equal(env.OPENCODE_CONFIG_PROJECT_DISABLE, 'true');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('stale-lock recovery is serialized across independent processes', async () => {
|
|
78
|
+
const root = await mkdtemp(path.join(os.tmpdir(), 'ctg-stale-recovery-'));
|
|
79
|
+
const lock = path.join(root, 'lifecycle.lock');
|
|
80
|
+
const gate = path.join(root, 'go');
|
|
81
|
+
const worker = path.join(root, 'worker.mjs');
|
|
82
|
+
const markerDir = path.join(root, 'active');
|
|
83
|
+
await mkdir(markerDir);
|
|
84
|
+
await writeFile(lock, JSON.stringify({ pid: 2_000_000_000, token: 'dead-owner', startedAt: 0 }));
|
|
85
|
+
await writeFile(worker, `
|
|
86
|
+
import { readFile, rm, writeFile } from 'node:fs/promises';
|
|
87
|
+
import { withFileLock } from ${JSON.stringify(new URL('../lib/opencode/runtime.mjs', import.meta.url).href)};
|
|
88
|
+
const [lock, gate, markerDir, name] = process.argv.slice(2);
|
|
89
|
+
while (true) { try { await readFile(gate); break; } catch { await new Promise(r => setTimeout(r, 5)); } }
|
|
90
|
+
await withFileLock(lock, async () => {
|
|
91
|
+
const marker = markerDir + '/' + name;
|
|
92
|
+
await writeFile(marker, 'active');
|
|
93
|
+
const current = await (await import('node:fs/promises')).readdir(markerDir);
|
|
94
|
+
if (current.includes('a') && current.includes('b')) await writeFile(markerDir + '/overlap', current.join(','));
|
|
95
|
+
await new Promise(r => setTimeout(r, 120));
|
|
96
|
+
await rm(marker);
|
|
97
|
+
}, { staleLockAge: 0, timeout: 5000, pollInterval: 2, afterStaleLockCheck: async () => {
|
|
98
|
+
await writeFile(markerDir + '/checked-' + name, 'yes');
|
|
99
|
+
while (true) {
|
|
100
|
+
try { await readFile(markerDir + '/checked-a'); await readFile(markerDir + '/checked-b'); break; }
|
|
101
|
+
catch { await new Promise(r => setTimeout(r, 2)); }
|
|
102
|
+
}
|
|
103
|
+
} });
|
|
104
|
+
`);
|
|
105
|
+
try {
|
|
106
|
+
const a = spawn(process.execPath, [worker, lock, gate, markerDir, 'a'], { stdio: 'ignore' });
|
|
107
|
+
const b = spawn(process.execPath, [worker, lock, gate, markerDir, 'b'], { stdio: 'ignore' });
|
|
108
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
109
|
+
await writeFile(gate, 'go');
|
|
110
|
+
const exited = Promise.all([a, b].map((child) => new Promise((resolve, reject) => {
|
|
111
|
+
child.once('error', reject);
|
|
112
|
+
child.once('exit', (code) => code === 0 ? resolve() : reject(new Error(`lock worker exited ${code}`)));
|
|
113
|
+
})));
|
|
114
|
+
await exited;
|
|
115
|
+
await assert.rejects(readFile(path.join(markerDir, 'overlap')), { code: 'ENOENT' });
|
|
116
|
+
} finally { await rm(root, { recursive: true, force: true }); }
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test('persistent stale recovery marker fails closed at the lock deadline with manual recovery guidance', async () => {
|
|
120
|
+
const root = await mkdtemp(path.join(os.tmpdir(), 'ctg-stale-recovery-stuck-'));
|
|
121
|
+
const lock = path.join(root, 'lifecycle.lock');
|
|
122
|
+
try {
|
|
123
|
+
await writeFile(lock, JSON.stringify({ pid: 2_000_000_000, token: 'dead-owner', startedAt: 0 }));
|
|
124
|
+
await writeFile(`${lock}.recovery`, JSON.stringify({ pid: 2_000_000_000, token: 'abandoned-recovery' }));
|
|
125
|
+
const startedAt = Date.now();
|
|
126
|
+
await assert.rejects(withFileLock(lock, async () => assert.fail('must not acquire lock'), {
|
|
127
|
+
timeout: 75,
|
|
128
|
+
pollInterval: 250,
|
|
129
|
+
staleLockAge: 0,
|
|
130
|
+
}), (error) => {
|
|
131
|
+
assert.match(error.message, /Timed out waiting for CTG runtime lock/);
|
|
132
|
+
assert.match(error.message, /inspect and remove the stale lock\/recovery files/);
|
|
133
|
+
assert.ok(error.message.includes(`${lock}.recovery`), 'manual guidance must name the recovery marker path');
|
|
134
|
+
return true;
|
|
135
|
+
});
|
|
136
|
+
assert.ok(Date.now() - startedAt < 400, 'recovery-marker retries must observe the bounded deadline');
|
|
137
|
+
assert.equal((await readFile(`${lock}.recovery`, 'utf8')).includes('abandoned-recovery'), true, 'stale marker remains for deliberate manual recovery');
|
|
138
|
+
} finally { await rm(root, { recursive: true, force: true }); }
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test('profile provisioning creates private isolated profile and absolute plugin reference', async () => {
|
|
142
|
+
const root = await mkdtemp(path.join(os.tmpdir(), 'ctg-runtime-'));
|
|
143
|
+
try {
|
|
144
|
+
const paths = await provisionRuntime({ root, pluginPath: path.join(root, 'plugins/carthagent'), profile: { model: 'fake/test' } });
|
|
145
|
+
const config = JSON.parse(await readFile(paths.configFile, 'utf8'));
|
|
146
|
+
assert.equal(config.model, 'fake/test');
|
|
147
|
+
assert.deepEqual(config.plugins, [path.join(root, 'plugins/carthagent')]);
|
|
148
|
+
if (process.platform !== 'win32') assert.equal((await stat(paths.configFile)).mode & 0o777, 0o600);
|
|
149
|
+
assert.equal((await stat(paths.appData)).isDirectory(), true);
|
|
150
|
+
assert.equal((await stat(paths.localAppData)).isDirectory(), true);
|
|
151
|
+
await writeFile(paths.configFile, JSON.stringify({ model: 'kept/model', plugins: ['kept-plugin'], commands: { keep: 'hello' } }));
|
|
152
|
+
await provisionRuntime({ root, pluginPath: path.join(root, 'plugins/carthagent'), profile: { theme: 'kept-theme' } });
|
|
153
|
+
const updated = JSON.parse(await readFile(paths.configFile, 'utf8'));
|
|
154
|
+
assert.equal(updated.model, 'kept/model');
|
|
155
|
+
assert.equal(updated.theme, 'kept-theme');
|
|
156
|
+
assert.deepEqual(updated.commands, { keep: 'hello' });
|
|
157
|
+
assert.deepEqual(updated.plugins, ['kept-plugin', path.join(root, 'plugins/carthagent')]);
|
|
158
|
+
} finally { await rm(root, { recursive: true, force: true }); }
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test('workspace environment records workspace without leaking CTG XDG overrides', () => {
|
|
162
|
+
const workspace = path.resolve('/tmp/workspace');
|
|
163
|
+
const env = workspaceEnvironment(workspace, { XDG_CONFIG_HOME: '/host/config', OPENAI_API_KEY: 'secret' });
|
|
164
|
+
assert.equal(env.XDG_CONFIG_HOME, '/host/config');
|
|
165
|
+
assert.equal(env.CTG_WORKSPACE, workspace);
|
|
166
|
+
assert.equal(env.OPENAI_API_KEY, 'secret');
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test('native slash command conversion validates and preserves template', () => {
|
|
170
|
+
assert.deepEqual(nativeCommandConfig({ review: { description: 'Review', template: 'Inspect $ARGUMENTS', agent: 'build' } }), {
|
|
171
|
+
review: { description: 'Review', template: 'Inspect $ARGUMENTS', agent: 'build' },
|
|
172
|
+
});
|
|
173
|
+
assert.throws(() => nativeCommandConfig({ 'Bad Name': 'no' }), /Invalid slash command/);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test('native API requests preserve structured responses and reject status failures', async () => {
|
|
177
|
+
const original = globalThis.fetch;
|
|
178
|
+
try {
|
|
179
|
+
globalThis.fetch = async () => new Response('{"ok":true}', { status: 200 });
|
|
180
|
+
assert.deepEqual(await serviceRequest({ url: 'http://127.0.0.1:1', headers: {} }, 'GET', '/api/info'), { ok: true });
|
|
181
|
+
globalThis.fetch = async () => new Response('{"message":"bad"}', { status: 500 });
|
|
182
|
+
await assert.rejects(serviceRequest({ url: 'http://127.0.0.1:1', headers: {} }, 'GET', '/api/info'), /failed \(500\)/);
|
|
183
|
+
} finally { globalThis.fetch = original; }
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test('session API helpers use the v2 envelope, prompt payload, and endpoint contracts', async () => {
|
|
187
|
+
const original = globalThis.fetch;
|
|
188
|
+
const calls = [];
|
|
189
|
+
const work = path.resolve('/work');
|
|
190
|
+
const workspace = path.resolve('/workspace');
|
|
191
|
+
try {
|
|
192
|
+
globalThis.fetch = async (url, init) => {
|
|
193
|
+
calls.push({ url: String(url), init });
|
|
194
|
+
return new Response('{"data":{"id":"ses_example"}}', { status: 200 });
|
|
195
|
+
};
|
|
196
|
+
const service = { url: 'http://127.0.0.1:1234', headers: {} };
|
|
197
|
+
assert.deepEqual(await createSession(service, work), { id: 'ses_example' });
|
|
198
|
+
assert.equal(JSON.parse(calls[0].init.body).location.directory, work);
|
|
199
|
+
await promptSession(service, 'ses_example', 'hello');
|
|
200
|
+
assert.equal(new URL(calls[1].url).pathname, '/api/session/ses_example/prompt');
|
|
201
|
+
assert.equal(JSON.parse(calls[1].init.body).text, 'hello');
|
|
202
|
+
await setSessionEnvironment(service, 'ses_example', { API_KEY: 'fixture' }, { location: workspace });
|
|
203
|
+
assert.equal(calls[2].init.method, 'PUT');
|
|
204
|
+
assert.equal(new URL(calls[2].url).pathname, '/api/session/ses_example/environment');
|
|
205
|
+
assert.equal(JSON.parse(calls[2].init.body).variables.API_KEY, 'fixture');
|
|
206
|
+
assert.equal(JSON.parse(calls[2].init.body).variables.CTG_WORKSPACE, workspace);
|
|
207
|
+
assert.equal(new URL(calls[0].url).searchParams.get('location[directory]'), work);
|
|
208
|
+
await sessionMessages(service, 'ses_example', { location: { directory: workspace } });
|
|
209
|
+
assert.equal(new URL(calls[3].url).searchParams.get('location[directory]'), workspace);
|
|
210
|
+
await cancelSession(service, 'ses_example', { location: { directory: workspace }, timeout: 100 });
|
|
211
|
+
const interrupt = new URL(calls[4].url);
|
|
212
|
+
assert.equal(interrupt.pathname, '/api/session/ses_example/interrupt');
|
|
213
|
+
assert.equal(interrupt.searchParams.get('location[directory]'), workspace);
|
|
214
|
+
assert.equal(interrupt.searchParams.get('resume'), 'false');
|
|
215
|
+
assert.ok(calls[4].init.signal instanceof AbortSignal, 'interrupt requests have a bounded abort signal');
|
|
216
|
+
await getSession(service, 'ses_example', { location: workspace });
|
|
217
|
+
assert.equal(new URL(calls[5].url).pathname, '/api/session/ses_example');
|
|
218
|
+
assert.equal(new URL(calls[5].url).searchParams.get('location[directory]'), workspace);
|
|
219
|
+
} finally { globalThis.fetch = original; }
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test('native TUI attaches to the ensured authenticated service', () => {
|
|
223
|
+
let captured;
|
|
224
|
+
const service = {
|
|
225
|
+
url: 'http://127.0.0.1:4567',
|
|
226
|
+
info: { password: 'ctg-test-password' },
|
|
227
|
+
binary: '/ctg/opencode',
|
|
228
|
+
paths: { root: '/ctg/profile' },
|
|
229
|
+
};
|
|
230
|
+
launchTui(service, '/work', { sessionID: 'ses_123', spawnImpl: (...args) => (captured = args) });
|
|
231
|
+
assert.deepEqual(captured[0], '/ctg/opencode');
|
|
232
|
+
assert.deepEqual(captured[1], ['--server', 'http://127.0.0.1:4567', '/work', '--session', 'ses_123']);
|
|
233
|
+
assert.equal(captured[2].env.OPENCODE_PASSWORD, 'ctg-test-password');
|
|
234
|
+
assert.equal(captured[2].cwd, '/work');
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test('a live registered process is retained when health inspection fails', async () => {
|
|
238
|
+
const root = await mkdtemp(path.join(os.tmpdir(), 'ctg-live-unhealthy-'));
|
|
239
|
+
const paths = runtimePaths({ root });
|
|
240
|
+
const original = globalThis.fetch;
|
|
241
|
+
try {
|
|
242
|
+
await provisionRuntime({ root });
|
|
243
|
+
const record = { id: 'ctg-live', pid: process.pid, url: 'http://127.0.0.1:1', password: 'test', version: '2.0.32' };
|
|
244
|
+
await writeFile(paths.serviceFile, JSON.stringify(record));
|
|
245
|
+
globalThis.fetch = async () => { throw new Error('temporary outage'); };
|
|
246
|
+
const options = { root, binary: '/missing/binary' };
|
|
247
|
+
// stopService does not need to resolve an executable and must not discard ownership.
|
|
248
|
+
await assert.rejects(stopService(options), /alive but its service identity cannot be verified/);
|
|
249
|
+
assert.deepEqual(JSON.parse(await readFile(paths.serviceFile, 'utf8')), record);
|
|
250
|
+
} finally { globalThis.fetch = original; await rm(root, { recursive: true, force: true }); }
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
test('ensure and stop serialize on the same lifecycle lock', async () => {
|
|
254
|
+
const root = await mkdtemp(path.join(os.tmpdir(), 'ctg-lifecycle-race-'));
|
|
255
|
+
const script = path.join(root, 'fake-opencode.cjs');
|
|
256
|
+
const source = `if (process.argv[2] === '--version') { console.log('2.0.32'); process.exit(0); }\nconst http = require('node:http'); const server = http.createServer((req,res) => { if (req.url === '/api/info') { res.setHeader('content-type','application/json'); res.end(JSON.stringify({version:'2.0.32',pid:process.pid})); } else { res.statusCode=404; res.end(); } }); server.listen(Number(process.argv[process.argv.indexOf('--port')+1]), '127.0.0.1', () => console.log('server listening on http://127.0.0.1:'+server.address().port));\n`;
|
|
257
|
+
try {
|
|
258
|
+
await writeFile(script, source);
|
|
259
|
+
const spawnImpl = (command, args, options) => spawn(command, [script, ...args], options);
|
|
260
|
+
const options = { root, binary: process.execPath, spawnImpl, startupTimeout: 5000, stopTimeout: 3000 };
|
|
261
|
+
const [ensured, stopped] = await Promise.all([ensureService(options), stopService(options)]);
|
|
262
|
+
assert.equal(typeof stopped, 'boolean');
|
|
263
|
+
if (ensured?.pid) {
|
|
264
|
+
// It either remains the correctly registered live service or stop removed it.
|
|
265
|
+
const current = await readFile(runtimePaths({ root }).serviceFile, 'utf8').then(JSON.parse).catch(() => undefined);
|
|
266
|
+
if (current) assert.equal(current.pid, ensured.pid);
|
|
267
|
+
}
|
|
268
|
+
await stopService(options).catch(() => {});
|
|
269
|
+
assert.equal(await readFile(runtimePaths({ root }).serviceFile, 'utf8').then(JSON.parse).catch(() => undefined), undefined);
|
|
270
|
+
} finally { await rm(root, { recursive: true, force: true }); }
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
test('an explicitly selected unusable OpenCode binary does not fall back', async () => {
|
|
274
|
+
await assert.rejects(resolveOpenCode({ binary: '/missing/ctg-opencode' }), /ENOENT|not found/i);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
test('headless runner passes workspace environment and returns assistant content and status', async () => {
|
|
278
|
+
const original = globalThis.fetch;
|
|
279
|
+
const calls = [];
|
|
280
|
+
try {
|
|
281
|
+
globalThis.fetch = async (url, init = {}) => {
|
|
282
|
+
const pathname = new URL(url).pathname;
|
|
283
|
+
calls.push({ pathname, init });
|
|
284
|
+
if (pathname === '/api/session') return new Response('{"data":{"id":"ses_headless"}}');
|
|
285
|
+
if (pathname.endsWith('/environment')) return new Response(null, { status: 204 });
|
|
286
|
+
if (pathname.endsWith('/prompt')) return new Response('{"data":{"id":"user_headless"}}');
|
|
287
|
+
if (pathname.endsWith('/wait')) return new Response(null, { status: 204 });
|
|
288
|
+
if (/\/api\/session\/ses_headless$/.test(pathname)) return new Response('{"data":{"id":"ses_headless","outcome":"succeeded"}}');
|
|
289
|
+
if (pathname.endsWith('/message')) return new Response(JSON.stringify({ data: [{ type: 'assistant', finish: 'stop', content: [{ type: 'text', text: 'fixture result' }] }] }));
|
|
290
|
+
throw new Error(`Unexpected endpoint ${pathname}`);
|
|
291
|
+
};
|
|
292
|
+
const result = await runHeadless({ url: 'http://127.0.0.1:3333', headers: {} }, '/workspace', 'say the fixture', { env: { FIXTURE_KEY: 'injected' } });
|
|
293
|
+
assert.deepEqual(result.result, {
|
|
294
|
+
status: 'stop',
|
|
295
|
+
text: 'fixture result',
|
|
296
|
+
error: undefined,
|
|
297
|
+
message: { type: 'assistant', finish: 'stop', content: [{ type: 'text', text: 'fixture result' }] },
|
|
298
|
+
});
|
|
299
|
+
assert.equal(calls.find((call) => call.pathname.endsWith('/environment')).init.method, 'PUT');
|
|
300
|
+
assert.equal(JSON.parse(calls.find((call) => call.pathname.endsWith('/environment')).init.body).variables.FIXTURE_KEY, 'injected');
|
|
301
|
+
assert.equal(JSON.parse(calls.find((call) => call.pathname.endsWith('/prompt')).init.body).text, 'say the fixture');
|
|
302
|
+
assert.equal(result.outcome, 'succeeded');
|
|
303
|
+
assert.equal(result.terminal, true);
|
|
304
|
+
assert.equal(result.budgetReason, undefined, 'a successful terminal session has no budget failure reason');
|
|
305
|
+
} finally { globalThis.fetch = original; }
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
test('pre-aborted headless calls do not create or resume a session or return prior output', async () => {
|
|
309
|
+
const original = globalThis.fetch;
|
|
310
|
+
let requests = 0;
|
|
311
|
+
try {
|
|
312
|
+
globalThis.fetch = async () => { requests += 1; throw new Error('pre-aborted execution must not make API requests'); };
|
|
313
|
+
const controller = new AbortController();
|
|
314
|
+
controller.abort(new Error('already cancelled'));
|
|
315
|
+
const service = { url: 'http://127.0.0.1:3333', headers: {} };
|
|
316
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
317
|
+
const created = await runHeadless(service, '/workspace', 'new work', { signal: controller.signal });
|
|
318
|
+
assert.equal(created.outcome, 'interrupted');
|
|
319
|
+
assert.equal(created.result, undefined);
|
|
320
|
+
assert.deepEqual(created.messages, []);
|
|
321
|
+
assert.equal(created.accepted, undefined);
|
|
322
|
+
assert.equal(created.budgetReason, undefined);
|
|
323
|
+
assert.equal(getEventListeners(controller.signal, 'abort').length, 0, 'pre-aborted initial calls must not leak forwarding listeners');
|
|
324
|
+
const resumed = await runHeadless(service, '/workspace', 'more work', { sessionID: 'ses_old_success', signal: controller.signal });
|
|
325
|
+
assert.equal(resumed.outcome, 'interrupted');
|
|
326
|
+
assert.equal(resumed.session.id, 'ses_old_success');
|
|
327
|
+
assert.equal(resumed.result, undefined, 'must not reuse a prior successful assistant from the resumed session');
|
|
328
|
+
assert.deepEqual(resumed.messages, []);
|
|
329
|
+
assert.equal(getEventListeners(controller.signal, 'abort').length, 0, 'pre-aborted resumed calls must not leak forwarding listeners');
|
|
330
|
+
}
|
|
331
|
+
assert.equal(requests, 0);
|
|
332
|
+
} finally { globalThis.fetch = original; }
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
test('headless result uses newest terminal assistant from chronological conversation history', async () => {
|
|
336
|
+
const original = globalThis.fetch;
|
|
337
|
+
try {
|
|
338
|
+
globalThis.fetch = async (url) => {
|
|
339
|
+
const pathname = new URL(url).pathname;
|
|
340
|
+
if (pathname === '/api/session') return new Response('{"data":{"id":"ses_resume"}}');
|
|
341
|
+
if (pathname.endsWith('/environment') || pathname.endsWith('/wait')) return new Response(null, { status: 204 });
|
|
342
|
+
if (pathname.endsWith('/prompt')) return new Response('{"data":{"id":"user-new"}}');
|
|
343
|
+
if (pathname === '/api/session/ses_resume') return new Response('{"data":{"id":"ses_resume","outcome":"succeeded"}}');
|
|
344
|
+
if (pathname.endsWith('/message')) return new Response(JSON.stringify({ data: [
|
|
345
|
+
{ id: 'assistant-final', type: 'assistant', time: { created: 300 }, finish: 'stop', content: [{ type: 'text', text: 'final answer' }] },
|
|
346
|
+
{ id: 'assistant-tool', type: 'assistant', time: { created: 250 }, finish: 'tool-calls', content: [{ type: 'tool-call', text: '' }] },
|
|
347
|
+
{ id: 'user-new', type: 'user', time: { created: 200 }, content: [{ type: 'text', text: 'new prompt' }] },
|
|
348
|
+
{ id: 'assistant-old', type: 'assistant', time: { created: 100 }, finish: 'stop', content: [{ type: 'text', text: 'old result' }] },
|
|
349
|
+
] }));
|
|
350
|
+
throw new Error(`Unexpected endpoint ${pathname}`);
|
|
351
|
+
};
|
|
352
|
+
const result = await runHeadless({ url: 'http://127.0.0.1:3333', headers: {} }, '/workspace', 'continue', { sessionID: 'ses_resume' });
|
|
353
|
+
assert.deepEqual(result.messages.map((item) => item.id), ['user-new', 'assistant-tool', 'assistant-final']);
|
|
354
|
+
assert.equal(result.result.text, 'final answer');
|
|
355
|
+
assert.equal(result.result.message.id, 'assistant-final', 'a preceding tool-call assistant must not replace the final response');
|
|
356
|
+
assert.equal(result.budgetReason, undefined, 'a terminal successful resumed turn has no budget failure reason');
|
|
357
|
+
} finally { globalThis.fetch = original; }
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
test('headless fails closed for 503 outcome projection and preserves terminal idle errors', async () => {
|
|
361
|
+
const original = globalThis.fetch;
|
|
362
|
+
let projectionCalls = 0;
|
|
363
|
+
try {
|
|
364
|
+
globalThis.fetch = async (url) => {
|
|
365
|
+
const pathname = new URL(url).pathname;
|
|
366
|
+
if (pathname === '/api/session') return new Response('{"data":{"id":"ses_projection"}}');
|
|
367
|
+
if (pathname.endsWith('/environment') || pathname.endsWith('/wait')) return new Response(null, { status: 204 });
|
|
368
|
+
if (pathname.endsWith('/prompt')) return new Response('{"data":{"id":"inbox_projection"}}');
|
|
369
|
+
if (pathname === '/api/session/ses_projection') {
|
|
370
|
+
projectionCalls += 1;
|
|
371
|
+
return new Response('{"error":"projection unavailable"}', { status: 503 });
|
|
372
|
+
}
|
|
373
|
+
if (pathname.endsWith('/message')) return new Response(JSON.stringify({ data: [
|
|
374
|
+
{ id: 'idle-error', type: 'idle', outcome: 'failed', error: { message: 'provider offline' }, time: { created: 300 } },
|
|
375
|
+
{ id: 'user-current', type: 'user', time: { created: 200 } },
|
|
376
|
+
] }));
|
|
377
|
+
throw new Error(`Unexpected endpoint ${pathname}`);
|
|
378
|
+
};
|
|
379
|
+
const result = await runHeadless({ url: 'http://127.0.0.1:3333', headers: {} }, '/workspace', 'go', { timeout: 100 });
|
|
380
|
+
assert.equal(projectionCalls > 0, true);
|
|
381
|
+
assert.equal(result.outcome, 'failed');
|
|
382
|
+
assert.equal(result.terminal, true);
|
|
383
|
+
assert.equal(result.error.message, 'provider offline');
|
|
384
|
+
assert.equal(result.result, undefined, 'idle failure must not accidentally select an earlier assistant result');
|
|
385
|
+
} finally { globalThis.fetch = original; }
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
test('aborting headless execution sends a native interrupt for that session', async () => {
|
|
389
|
+
const original = globalThis.fetch;
|
|
390
|
+
const paths = [];
|
|
391
|
+
try {
|
|
392
|
+
globalThis.fetch = async (url, init = {}) => {
|
|
393
|
+
const pathname = new URL(url).pathname;
|
|
394
|
+
paths.push(pathname);
|
|
395
|
+
if (pathname === '/api/session') return new Response('{"data":{"id":"ses_cancel"}}');
|
|
396
|
+
if (pathname.endsWith('/environment')) return new Response(null, { status: 204 });
|
|
397
|
+
if (pathname.endsWith('/prompt')) return new Response('{"data":{"id":"inbox_cancel"}}');
|
|
398
|
+
if (pathname.endsWith('/wait')) return pendingUntilAbort(init.signal);
|
|
399
|
+
if (pathname.endsWith('/interrupt')) return new Response(null, { status: 204 });
|
|
400
|
+
throw new Error(`Unexpected endpoint ${pathname}`);
|
|
401
|
+
};
|
|
402
|
+
const controller = new AbortController();
|
|
403
|
+
const resultPromise = runHeadless({ url: 'http://127.0.0.1:3333', headers: {} }, '/workspace', 'wait', { signal: controller.signal });
|
|
404
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
405
|
+
controller.abort(new Error('test cancellation'));
|
|
406
|
+
const result = await resultPromise;
|
|
407
|
+
assert.equal(result.outcome, 'interrupted');
|
|
408
|
+
assert.equal(result.budgetReason, undefined);
|
|
409
|
+
assert.ok(paths.includes('/api/session/ses_cancel/interrupt'));
|
|
410
|
+
} finally { globalThis.fetch = original; }
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
test('headless timeout aborts the wait request and interrupts the native session', async () => {
|
|
414
|
+
const original = globalThis.fetch;
|
|
415
|
+
const paths = [];
|
|
416
|
+
try {
|
|
417
|
+
globalThis.fetch = async (url, init = {}) => {
|
|
418
|
+
const pathname = new URL(url).pathname;
|
|
419
|
+
paths.push(pathname);
|
|
420
|
+
if (pathname === '/api/session') return new Response('{"data":{"id":"ses_timeout"}}');
|
|
421
|
+
if (pathname.endsWith('/environment')) return new Response(null, { status: 204 });
|
|
422
|
+
if (pathname.endsWith('/prompt')) return new Response('{"data":{"id":"inbox_timeout"}}');
|
|
423
|
+
if (pathname.endsWith('/wait')) return pendingUntilAbort(init.signal);
|
|
424
|
+
if (pathname.endsWith('/interrupt')) return new Response(null, { status: 204 });
|
|
425
|
+
if (pathname.endsWith('/message')) return new Response('{"data":[{"id":"idle-timeout","type":"idle","outcome":"interrupted","time":{"created":3}}]}');
|
|
426
|
+
throw new Error(`Unexpected endpoint ${pathname}`);
|
|
427
|
+
};
|
|
428
|
+
const result = await runHeadless({ url: 'http://127.0.0.1:3333', headers: {} }, '/workspace', 'hang', { timeout: 30 });
|
|
429
|
+
assert.equal(result.outcome, 'interrupted');
|
|
430
|
+
assert.equal(result.terminal, true);
|
|
431
|
+
assert.equal(result.budgetReason, undefined);
|
|
432
|
+
assert.ok(paths.includes('/api/session/ses_timeout/interrupt'));
|
|
433
|
+
} finally { globalThis.fetch = original; }
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
test('headless reports timeout distinctly when no terminal outcome can be observed', async () => {
|
|
437
|
+
const original = globalThis.fetch;
|
|
438
|
+
try {
|
|
439
|
+
globalThis.fetch = async (url, init = {}) => {
|
|
440
|
+
const pathname = new URL(url).pathname;
|
|
441
|
+
if (pathname === '/api/session') return new Response('{"data":{"id":"ses_no_outcome"}}');
|
|
442
|
+
if (pathname.endsWith('/environment')) return new Response(null, { status: 204 });
|
|
443
|
+
if (pathname.endsWith('/prompt')) return new Response('{"data":{"id":"inbox_no_outcome"}}');
|
|
444
|
+
if (pathname.endsWith('/wait')) return pendingUntilAbort(init.signal);
|
|
445
|
+
if (pathname.endsWith('/interrupt')) return new Response(null, { status: 204 });
|
|
446
|
+
if (pathname.endsWith('/message')) return new Response('{"data":[]}');
|
|
447
|
+
if (pathname === '/api/session/ses_no_outcome') return new Response('{"data":{"id":"ses_no_outcome"}}');
|
|
448
|
+
throw new Error(`Unexpected endpoint ${pathname}`);
|
|
449
|
+
};
|
|
450
|
+
const result = await runHeadless({ url: 'http://127.0.0.1:3333', headers: {} }, '/workspace', 'hang', { timeout: 25 });
|
|
451
|
+
assert.equal(result.outcome, 'timed-out');
|
|
452
|
+
assert.equal(result.terminal, false);
|
|
453
|
+
assert.equal(result.budgetReason, 'timed-out');
|
|
454
|
+
} finally { globalThis.fetch = original; }
|
|
455
|
+
});
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { createJiti } from 'jiti';
|
|
4
|
+
|
|
5
|
+
const jiti = createJiti(import.meta.url);
|
|
6
|
+
const { registerSessionControls } = await jiti.import('../plugins/carthagent/src/server/session-controls.ts');
|
|
7
|
+
|
|
8
|
+
function fixture(options = {}, sessions = {}) {
|
|
9
|
+
const values = new Map();
|
|
10
|
+
const hooks = {};
|
|
11
|
+
const calls = [];
|
|
12
|
+
let handlers;
|
|
13
|
+
const ctx = {
|
|
14
|
+
options, location: { directory: '/project' },
|
|
15
|
+
storage: { get: async key => values.get(key), set: async (key, value) => values.set(key, value) },
|
|
16
|
+
session: {
|
|
17
|
+
get: async ({ sessionID }) => ({ location: { directory: sessionID === 'foreign' ? '/other' : '/project' }, ...sessions[sessionID] }),
|
|
18
|
+
hook: async (name, hook) => { hooks[name] = hook; return { dispose: async () => { delete hooks[name]; } }; },
|
|
19
|
+
synthetic: async input => { calls.push(['synthetic', input]); },
|
|
20
|
+
compact: async input => { calls.push(['compact', input]); },
|
|
21
|
+
},
|
|
22
|
+
rpc: { register: async (_definition, implementation) => { handlers = implementation; return { dispose: async () => { handlers = undefined; } }; } },
|
|
23
|
+
};
|
|
24
|
+
return { ctx, hooks, calls, values, get handlers() { return handlers; } };
|
|
25
|
+
}
|
|
26
|
+
const call = () => ({
|
|
27
|
+
signal: new AbortController().signal,
|
|
28
|
+
error(type, message, data) { throw Object.assign(new Error(message), { type, data }); },
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('native framing is session-scoped, raw-safe and preserves attachments', async () => {
|
|
32
|
+
const f = fixture();
|
|
33
|
+
const cleanup = await registerSessionControls(f.ctx);
|
|
34
|
+
const event = { sessionID: 'a', prompt: { text: 'Implement a parser', files: [{ uri: 'file:///project/input', mention: { start: 0, end: 2, text: 'im' } }] } };
|
|
35
|
+
await f.hooks.prompt(event);
|
|
36
|
+
assert.notEqual(event.prompt.text, 'Implement a parser');
|
|
37
|
+
assert.equal(event.metadata['ctg.original'], 'Implement a parser');
|
|
38
|
+
assert.equal(event.prompt.files[0].uri, 'file:///project/input');
|
|
39
|
+
assert.equal(event.prompt.files[0].mention, undefined);
|
|
40
|
+
const once = event.prompt.text;
|
|
41
|
+
await f.hooks.prompt(event);
|
|
42
|
+
assert.equal(event.prompt.text, once);
|
|
43
|
+
const raw = { sessionID: 'a', prompt: { text: 'Implement a parser' }, metadata: { 'ctg.raw': true } };
|
|
44
|
+
await f.hooks.prompt(raw);
|
|
45
|
+
assert.equal(raw.prompt.text, 'Implement a parser');
|
|
46
|
+
await f.handlers.framing({ sessionID: 'a', enabled: false }, call());
|
|
47
|
+
const a = { sessionID: 'a', prompt: { text: 'Implement a parser' } };
|
|
48
|
+
const b = { sessionID: 'b', prompt: { text: 'Implement a parser' } };
|
|
49
|
+
await f.hooks.prompt(a);
|
|
50
|
+
await f.hooks.prompt(b);
|
|
51
|
+
assert.equal(a.prompt.text, 'Implement a parser');
|
|
52
|
+
assert.notEqual(b.prompt.text, a.prompt.text);
|
|
53
|
+
await assert.rejects(f.handlers.framing({ sessionID: 'foreign' }, call()), { type: 'wrong_workspace', data: { sessionID: 'foreign' } });
|
|
54
|
+
await cleanup();
|
|
55
|
+
assert.equal(f.hooks.prompt, undefined);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('concurrent framing toggles serialize and override a disabled launch default', async () => {
|
|
59
|
+
const f = fixture({ guide: false });
|
|
60
|
+
await registerSessionControls(f.ctx);
|
|
61
|
+
const results = await Promise.all([f.handlers.framing({ sessionID: 'a' }, call()), f.handlers.framing({ sessionID: 'a' }, call())]);
|
|
62
|
+
assert.deepEqual(results, [{ enabled: true }, { enabled: false }]);
|
|
63
|
+
await f.handlers.framing({ sessionID: 'a', enabled: true }, call());
|
|
64
|
+
const event = { sessionID: 'a', prompt: { text: 'Implement a parser' } };
|
|
65
|
+
await f.hooks.prompt(event);
|
|
66
|
+
assert.notEqual(event.prompt.text, 'Implement a parser');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('compaction notes precede queued compaction and cancellation prevents admission', async () => {
|
|
70
|
+
const f = fixture();
|
|
71
|
+
await registerSessionControls(f.ctx);
|
|
72
|
+
assert.deepEqual(await f.handlers.compact({ sessionID: 'a', notes: 'Preserve unresolved decisions' }, call()), { admitted: true });
|
|
73
|
+
assert.deepEqual(f.calls.map(item => item[0]), ['synthetic', 'compact']);
|
|
74
|
+
assert.match(f.calls[0][1].text, /Preserve unresolved decisions/);
|
|
75
|
+
assert.ok(f.calls.every(item => item[1].sessionID === 'a' && item[1].delivery === 'queue'));
|
|
76
|
+
await assert.rejects(f.handlers.compact({ sessionID: 'a' }, { signal: AbortSignal.abort() }));
|
|
77
|
+
assert.equal(f.calls.length, 2);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('persisted per-session launcher settings control framing without affecting neighbors', async () => {
|
|
81
|
+
const f = fixture({}, {
|
|
82
|
+
a: { metadata: { 'carthagent.options': { guide: false } } },
|
|
83
|
+
b: { metadata: { 'carthagent.options': { delivery: false } } },
|
|
84
|
+
});
|
|
85
|
+
await registerSessionControls(f.ctx);
|
|
86
|
+
for (const sessionID of ['a', 'b']) {
|
|
87
|
+
const event = { sessionID, prompt: { text: 'Implement a parser' } };
|
|
88
|
+
await f.hooks.prompt(event);
|
|
89
|
+
assert.equal(event.prompt.text, 'Implement a parser');
|
|
90
|
+
}
|
|
91
|
+
assert.deepEqual(await f.handlers.framing({ sessionID: 'a' }, call()), { enabled: true });
|
|
92
|
+
const event = { sessionID: 'a', prompt: { text: 'Implement a parser' } };
|
|
93
|
+
await f.hooks.prompt(event);
|
|
94
|
+
assert.notEqual(event.prompt.text, 'Implement a parser');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('queued controls recheck ownership after a session moves', async () => {
|
|
98
|
+
const f = fixture();
|
|
99
|
+
let releaseWrite;
|
|
100
|
+
let beganWrite;
|
|
101
|
+
const writing = new Promise(resolve => { beganWrite = resolve; });
|
|
102
|
+
const gate = new Promise(resolve => { releaseWrite = resolve; });
|
|
103
|
+
let directory = '/project';
|
|
104
|
+
f.ctx.session.get = async () => ({ location: { directory } });
|
|
105
|
+
f.ctx.storage.set = async () => { beganWrite(); await gate; };
|
|
106
|
+
await registerSessionControls(f.ctx);
|
|
107
|
+
const first = f.handlers.framing({ sessionID: 'a', enabled: true }, call());
|
|
108
|
+
await writing;
|
|
109
|
+
const queued = f.handlers.compact({ sessionID: 'a', notes: 'Must not be admitted after move' }, call());
|
|
110
|
+
directory = '/other';
|
|
111
|
+
releaseWrite();
|
|
112
|
+
await first;
|
|
113
|
+
await assert.rejects(queued, { type: 'wrong_workspace', data: { sessionID: 'a' } });
|
|
114
|
+
assert.deepEqual(f.calls, []);
|
|
115
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { resolve } from 'node:path';
|
|
4
|
+
import { effectiveSessionOptions, normalizeSessionOptions, sessionOptionsMetadata } from '../lib/opencode/session-options.mjs';
|
|
5
|
+
|
|
6
|
+
test('native session options isolate concurrent settings and resolve paths at the owning worktree', () => {
|
|
7
|
+
const defaults = { maxTools: 5, workflow: 'standard' };
|
|
8
|
+
const a = { location: { directory: resolve('first') }, metadata: sessionOptionsMetadata({ maxTools: '8', guide: false, validators: 'checks.json' }, resolve('first'), { other: true }) };
|
|
9
|
+
const b = { location: { directory: resolve('second') }, metadata: sessionOptionsMetadata({ maxTools: 2, workflow: 'strict' }, resolve('second')) };
|
|
10
|
+
assert.equal(effectiveSessionOptions(a, defaults).maxTools, 8);
|
|
11
|
+
assert.equal(effectiveSessionOptions(b, defaults).maxTools, 2);
|
|
12
|
+
assert.equal(effectiveSessionOptions(a, defaults).validators, resolve('first/checks.json'));
|
|
13
|
+
assert.equal(effectiveSessionOptions(a, defaults).guide, false);
|
|
14
|
+
assert.equal(effectiveSessionOptions(b, defaults).guide, true);
|
|
15
|
+
assert.deepEqual(defaults, { maxTools: 5, workflow: 'standard' });
|
|
16
|
+
assert.equal(a.metadata.other, true);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test('invalid persisted limits fail closed and non-runtime options never enter metadata', () => {
|
|
20
|
+
for (const options of [{ maxTools: -1 }, { maxSeconds: 'no' }, { codegraphMaxFiles: 0 }, { guide: 'false' }, { workflow: 'anything' }]) {
|
|
21
|
+
assert.throws(() => normalizeSessionOptions(options));
|
|
22
|
+
}
|
|
23
|
+
assert.deepEqual(normalizeSessionOptions({ delivery: false, maxTools: 1 }), { delivery: false, maxTools: 1 }, 'budget remains configured independently of delivery workflow participation');
|
|
24
|
+
assert.deepEqual(normalizeSessionOptions({ apiKey: 'secret', theme: 'carthage', model: 'provider/model', guide: false }), { guide: false });
|
|
25
|
+
assert.throws(() => effectiveSessionOptions({ location: { directory: resolve('.') }, metadata: { 'carthagent.options': [] } }), /persisted/);
|
|
26
|
+
});
|