carthagent 1.0.7 → 1.1.0-preview.13
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/README.md +140 -83
- package/bin/ctg.mjs +14 -732
- package/docs/glyph-review/host-chrome-glyph-compatibility-v4.md +83 -0
- package/docs/glyph-review/test-isolation-v1.md +26 -0
- package/docs/glyph-review/typecheck-base.log +1 -0
- package/docs/glyph-review/typecheck-v4.log +1 -0
- package/docs/glyph-review/unicode-inventory-v4.csv +402 -0
- package/docs/help-dispatch-review.md +69 -0
- package/docs/legacy-cli.md +150 -0
- package/docs/live-theme-plugin-api.md +18 -0
- package/docs/native-cli.md +102 -0
- package/docs/native-compatibility.md +16 -0
- package/docs/native-daily-quests.md +132 -0
- package/docs/native-glyph-pty.md +59 -0
- package/docs/native-manual-testing.md +336 -0
- package/docs/native-slash-acceptance-handoff.md +49 -0
- package/docs/native-terminal-paired-failure-2026-10-17.md +40 -0
- package/docs/native-terminal-performance.md +310 -0
- package/docs/native-terminal-semantics-evidence/README.md +25 -0
- package/docs/native-terminal-semantics-evidence/controlled-host-model-selftest.json +16 -0
- package/docs/native-terminal-semantics-evidence/current-acceptance-status.json +59 -0
- package/docs/native-terminal-semantics-evidence/final-binary-focused-85bb44a.json +154 -0
- package/docs/native-terminal-semantics-evidence/final-binary-full-incomplete-85bb44a.json +160 -0
- package/docs/native-terminal-semantics-evidence/final-binary-layout-85bb44a.json +149 -0
- package/docs/native-terminal-semantics-evidence/final-binary-layout-interactive-85bb44a.json +159 -0
- package/docs/native-terminal-semantics-evidence/final-binary-model-picker-escape-unchanged-85bb44a.json +44 -0
- package/docs/native-terminal-semantics-evidence/final-binary-model-picker-exact-identity-85bb44a.json +47 -0
- package/docs/native-terminal-semantics-evidence/final-binary-model-picker-unchanged-model-incomplete-85bb44a.json +58 -0
- package/docs/native-terminal-semantics-evidence/final-binary-model-selftest.json +16 -0
- package/docs/native-terminal-semantics-evidence/final-binary-picker-cancel-incomplete-85bb44a.json +62 -0
- package/docs/native-terminal-semantics-evidence/focused-clipboard-title-stale-binary.json +161 -0
- package/docs/native-terminal-semantics-evidence/layout-stale-binary-observation-not-accepted.json +153 -0
- package/docs/native-terminal-semantics-evidence/picker-latest-incomplete-attempt.json +62 -0
- package/docs/native-terminal-semantics-evidence/picker-previous-probe-pass.json +61 -0
- package/docs/native-terminal-semantics.md +89 -0
- package/docs/native-upgrade.md +177 -0
- package/docs/opencode-composed-source-review.md +89 -0
- package/docs/opencode-migration.md +225 -9
- package/docs/opencode-owned-runtime.md +241 -0
- package/docs/opencode-runtime.md +12 -0
- package/docs/opencode-theme-provider-proof/README.md +55 -0
- package/docs/opencode-theme-provider-proof/live-theme-api-base.patch +122 -0
- package/docs/opencode-theme-provider-proof/plugin-theme-provider.patch +107 -0
- package/docs/opencode-theme-provider-proof/session-ui-base.patch +1217 -0
- package/docs/owned-host-controls-proof.md +39 -0
- package/docs/owned-runtime-release-manifest.md +42 -0
- package/docs/owned-visual-clear-proof.md +40 -0
- package/docs/patches/opencode-v2.0.32-glyph-spinner-test-isolation-v1.patch +50 -0
- package/docs/patches/opencode-v2.0.32-glyphs.patch +3670 -0
- package/docs/patches/opencode-v2.0.32-help-dispatch.patch +156 -0
- package/docs/patches/opencode-v2.0.32-live-theme-api.patch +122 -0
- package/docs/patches/opencode-v2.0.32-session-ui-combined-v1.patch +5272 -0
- package/docs/patches/opencode-v2.0.32-session-ui-combined-v2.patch +6052 -0
- package/docs/patches/opencode-v2.0.32-session-ui-combined-v3.patch +8657 -0
- package/docs/patches/opencode-v2.0.32-session-ui-combined-v4.patch +9928 -0
- package/docs/patches/opencode-v2.0.32-session-ui-combined-v5.patch +13965 -0
- package/docs/patches/opencode-v2.0.32-session-ui.patch +5137 -0
- package/docs/release-notes-native-draft.md +73 -0
- package/docs/release-notes-preview.10.md +121 -0
- package/docs/release-notes-preview.11.md +76 -0
- package/docs/release-notes-preview.12.md +55 -0
- package/docs/release-notes-preview.13.md +68 -0
- package/docs/release-notes-preview.9.md +130 -0
- package/docs/source-preview.md +120 -0
- package/evaluate.mjs +28 -1
- package/lib/budget.mjs +1 -1
- package/lib/codegraph.mjs +1 -1
- package/lib/delivery-review.mjs +11 -1
- package/lib/delivery.mjs +3 -2
- package/lib/environment.mjs +8 -0
- package/lib/guidance.mjs +1 -1
- package/lib/opencode/cli-runtime.mjs +1171 -0
- package/lib/opencode/cli.mjs +252 -0
- package/lib/opencode/credentials.mjs +63 -8
- package/lib/opencode/demo.mjs +53 -0
- package/lib/opencode/evaluation-budget-plugin/index.ts +3 -0
- package/lib/opencode/evaluation.mjs +32 -12
- package/lib/opencode/headless.mjs +8 -4
- package/lib/opencode/provider-policy.mjs +23 -0
- package/lib/opencode/review.mjs +37 -16
- package/lib/opencode/runtime-artifact.mjs +335 -0
- package/lib/opencode/runtime-github-transport.mjs +121 -0
- package/lib/opencode/runtime-release.json +36 -0
- package/lib/opencode/runtime.mjs +299 -20
- package/lib/opencode/service.mjs +77 -35
- package/lib/opencode/session-options.mjs +6 -2
- package/lib/opencode/update.mjs +222 -0
- package/lib/opencode/workspace.mjs +1 -1
- package/lib/planning-advice.mjs +2 -2
- package/lib/providers/experiential.mjs +2 -2
- package/lib/tui/framing.mjs +1 -80
- package/lib/update.mjs +10 -1
- package/package.json +33 -7
- package/plugins/carthagent/README.md +23 -0
- package/plugins/carthagent/src/bootstrap.ts +1 -1
- package/plugins/carthagent/src/daily-evidence.mjs +57 -0
- package/plugins/carthagent/src/daily-quests-rpc.ts +50 -0
- package/plugins/carthagent/src/daily-source-rpc.ts +52 -0
- package/plugins/carthagent/src/index.ts +71 -24
- package/plugins/carthagent/src/rpc.ts +51 -6
- package/plugins/carthagent/src/server/codegraph.ts +12 -5
- package/plugins/carthagent/src/server/daily-quests-rpc.ts +277 -0
- package/plugins/carthagent/src/server/daily-source-rpc.ts +211 -0
- package/plugins/carthagent/src/server/delivery.ts +227 -112
- package/plugins/carthagent/src/server/framing.mjs +80 -0
- package/plugins/carthagent/src/server/policy.ts +1 -1
- package/plugins/carthagent/src/server/providers.ts +59 -31
- package/plugins/carthagent/src/server/session-controls.ts +3 -1
- package/plugins/carthagent/src/server/ship-agents.ts +77 -0
- package/plugins/carthagent/src/server/ship-context.mjs +60 -0
- package/plugins/carthagent/src/server/ship-guidance.ts +41 -0
- package/plugins/carthagent/src/server/ship-rpc.ts +69 -0
- package/plugins/carthagent/src/server/voyages-rpc.ts +223 -0
- package/plugins/carthagent/src/testing.ts +5 -0
- package/plugins/carthagent/src/tui.tsx +496 -0
- package/plugins/carthagent/src/ui/client.ts +125 -0
- package/plugins/carthagent/src/ui/commands.ts +70 -0
- package/plugins/carthagent/src/ui/daily-editor-focus.mjs +52 -0
- package/plugins/carthagent/src/ui/daily-editor.mjs +4 -0
- package/plugins/carthagent/src/ui/daily-quests-events.mjs +18 -0
- package/plugins/carthagent/src/ui/daily-quests-state.mjs +218 -0
- package/plugins/carthagent/src/ui/daily-quests.tsx +235 -0
- package/plugins/carthagent/src/ui/host-state.ts +42 -0
- package/plugins/carthagent/src/ui/model-command.ts +32 -0
- package/plugins/carthagent/src/ui/native-navigation.ts +23 -0
- package/plugins/carthagent/src/ui/native-review.d.mts +33 -0
- package/plugins/carthagent/src/ui/native-review.mjs +159 -0
- package/plugins/carthagent/src/ui/resume-session.mjs +34 -0
- package/plugins/carthagent/src/ui/select-mouse.ts +17 -0
- package/plugins/carthagent/src/ui/session-preferences.d.mts +7 -0
- package/plugins/carthagent/src/ui/session-preferences.mjs +14 -0
- package/plugins/carthagent/src/ui/theme-provision.d.mts +3 -0
- package/plugins/carthagent/src/ui/theme-provision.mjs +134 -0
- package/plugins/carthagent/src/ui/voyages-navigation-state.mjs +56 -0
- package/plugins/carthagent/src/ui/voyages-navigation.tsx +131 -0
- package/plugins/carthagent/src/ui/voyages-state.mjs +142 -0
- package/plugins/carthagent/src/ui/voyages.tsx +129 -0
- package/plugins/carthagent/src/ui/workspace-live.ts +61 -0
- package/plugins/carthagent/src/ui/workspace.tsx +379 -0
- package/plugins/carthagent/src/voyages-rpc.ts +60 -0
- package/plugins/carthagent/src/workspace-rpc.ts +36 -0
- package/plugins/carthagent/tests/commands.test.ts +107 -0
- package/plugins/carthagent/tests/session-preferences.test.mjs +41 -0
- package/plugins/carthagent/tests/voyages-navigation.test.tsx +269 -0
- package/plugins/carthagent/themes/carthage.json +67 -0
- package/plugins/carthagent/tui.ts +26 -1
- package/script/ctg/build-owned-runtime.sh +166 -0
- package/tests/budget-engine.test.mjs +4 -3
- package/tests/cli.test.mjs +52 -96
- package/tests/daily-evidence.test.mjs +62 -0
- package/tests/delivery-review.test.mjs +11 -0
- package/tests/delivery.test.mjs +22 -1
- package/tests/fixtures/native-discovery-plugin/server.ts +15 -0
- package/tests/generate-owned-runtime-release.test.mjs +354 -0
- package/tests/installed-runtime-contract.test.mjs +43 -0
- package/tests/native-cli-host-smoke.mjs +457 -0
- package/tests/native-daily-editor-focus.test.mjs +213 -0
- package/tests/native-daily-editor.test.mjs +43 -0
- package/tests/native-daily-quests-layout.test.mjs +53 -0
- package/tests/native-daily-quests-rpc.test.mjs +456 -0
- package/tests/native-daily-quests-ui.test.mjs +778 -0
- package/tests/native-daily-source.test.mjs +451 -0
- package/tests/native-demo-ui.test.mjs +48 -0
- package/tests/native-evaluation-admission.test.mjs +24 -0
- package/tests/native-fixture-browser-cleanup.test.mjs +65 -0
- package/tests/native-fixture-runtime.test.mjs +111 -0
- package/tests/native-fixture-stop-diagnostics.test.mjs +67 -0
- package/tests/native-glyph-pty.test.mjs +107 -0
- package/tests/native-manual-projects.test.mjs +170 -0
- package/tests/native-model-command.test.mjs +67 -0
- package/tests/native-owned-runtime-env.test.mjs +140 -0
- package/tests/native-paired-guards.test.mjs +464 -0
- package/tests/native-provider-policy.test.mjs +31 -0
- package/tests/native-pty-invocation.test.mjs +114 -0
- package/tests/native-pty-runtime.test.mjs +45 -0
- package/tests/native-resume-session.test.mjs +46 -0
- package/tests/native-review-consumer.test.mjs +286 -0
- package/tests/native-runtime-identity.test.mjs +214 -0
- package/tests/native-select-mouse.test.mjs +23 -0
- package/tests/native-ship-parity.test.mjs +526 -0
- package/tests/native-ship-plan.test.mjs +85 -0
- package/tests/native-ship-rpc.test.mjs +71 -0
- package/tests/native-ui-commands.test.mjs +6 -0
- package/tests/native-voyages-layout.test.mjs +28 -0
- package/tests/native-voyages-navigation.test.mjs +169 -0
- package/tests/native-voyages-rpc.test.mjs +395 -0
- package/tests/native-voyages-ui.test.mjs +277 -0
- package/tests/opencode-atomic-write.test.mjs +55 -0
- package/tests/opencode-cli.test.mjs +1312 -0
- package/tests/opencode-codegraph-plugin.test.mjs +47 -0
- package/tests/opencode-delivery.test.mjs +16 -0
- package/tests/opencode-providers.test.mjs +245 -18
- package/tests/opencode-runtime.test.mjs +546 -8
- package/tests/opencode-session-controls.test.mjs +15 -0
- package/tests/opencode-ship-rpc.test.mjs +71 -0
- package/tests/opencode-update.test.mjs +475 -0
- package/tests/opencode-workspace.test.mjs +51 -2
- package/tests/owned-runtime-publish-workflow.test.mjs +477 -0
- package/tests/release-workflow.test.mjs +46 -0
- package/tests/runtime-artifact.test.mjs +269 -0
- package/tests/runtime-development-trust.test.mjs +230 -0
- package/tests/runtime-github-transport.test.mjs +132 -0
- package/tests/runtime-token-env.test.mjs +23 -0
- package/tests/server.test.mjs +3 -1
- package/tests/ship-telemetry.test.mjs +1 -1
- package/tests/source-preview.test.mjs +253 -0
- package/tests/test_native_terminal_probe.py +76 -0
- package/tests/test_native_terminal_semantics.py +232 -0
- package/tests/update.test.mjs +42 -5
- package/tests/verify-draft-runtime-gate.test.mjs +280 -0
- package/tests/verify-owned-runtime-release.test.mjs +371 -0
- package/tests/workspace-live.test.mjs +127 -0
- package/tools/benchmark/diagnose-request.mjs +59 -36
- package/tools/benchmark/eval-common.mjs +3 -3
- package/tools/benchmark/evaluators.test.mjs +4 -4
- package/tools/benchmark/guard.test.mjs +35 -2
- package/tools/benchmark/guard.ts +20 -7
- package/tools/benchmark/planning-startup.test.mjs +30 -34
- package/tools/benchmark/runner.mjs +12 -7
- package/tools/benchmark/runner.test.mjs +23 -7
- package/tools/benchmark/upstream-error.test.mjs +16 -2
- package/tools/generate-owned-runtime-release.mjs +217 -0
- package/tools/installed-runtime-contract.mjs +16 -0
- package/tools/manual-runtime-manifest.mjs +39 -0
- package/tools/native-fixture-browser-cleanup.mjs +59 -0
- package/tools/native-fixture-stop-diagnostics.mjs +43 -0
- package/tools/native-fixture.mjs +199 -40
- package/tools/native-glyph-helpers.py +127 -0
- package/tools/native-glyph-pty.py +144 -0
- package/tools/native-manual-projects.mjs +259 -0
- package/tools/native-manual.mjs +338 -0
- package/tools/native-model-picker-acceptance.py +306 -0
- package/tools/native-owned-runtime-env.sh +47 -0
- package/tools/native-paired-guards.mjs +282 -0
- package/tools/native-paired-manifest.mjs +72 -0
- package/tools/native-paired-orchestration.sh +51 -0
- package/tools/native-plugin-readiness.mjs +33 -0
- package/tools/native-plugin-readiness.test.mjs +53 -0
- package/tools/native-pty-invocation.mjs +26 -0
- package/tools/native-pty-runtime.mjs +83 -0
- package/tools/native-pty-smoke.py +147 -24
- package/tools/native-public-repo-shell-result.mjs +13 -0
- package/tools/native-public-repo-shell-result.test.mjs +28 -0
- package/tools/native-runtime-identity.mjs +201 -0
- package/tools/native-slash-acceptance-env.py +38 -0
- package/tools/native-terminal-semantics.py +876 -0
- package/tools/owned-shell-observer/index.ts +16 -0
- package/tools/owned-shell-observer/package.json +1 -0
- package/tools/owned-visual-clear-env.py +48 -0
- package/tools/owned-visual-clear-model.py +21 -0
- package/tools/pinned-owned-runtime-inputs.mjs +46 -0
- package/tools/prepare-native-manual.mjs +122 -0
- package/tools/run-native-terminal-paired.sh +403 -0
- package/tools/runtime-token-env.mjs +8 -0
- package/tools/seed-native-switch-fixture.mjs +79 -0
- package/tools/test-native-controls.mjs +3 -1
- package/tools/test-native-daily-quests.mjs +65 -0
- package/tools/test-native-default-cli.mjs +106 -0
- package/tools/test-native-delivery-host.mjs +102 -20
- package/tools/test-native-evaluation.mjs +7 -3
- package/tools/test-native-glyph-pty.mjs +94 -0
- package/tools/test-native-host.mjs +87 -63
- package/tools/test-native-outcomes.mjs +3 -1
- package/tools/test-native-package.mjs +206 -38
- package/tools/test-native-performance.mjs +26 -0
- package/tools/test-native-product.mjs +90 -0
- package/tools/test-native-pty.mjs +34 -9
- package/tools/test-native-public-repo.mjs +224 -0
- package/tools/test-native-refresh-reentry.mjs +106 -0
- package/tools/test-native-report.mjs +27 -20
- package/tools/test-native-review.mjs +75 -0
- package/tools/test-native-runtime.mjs +120 -0
- package/tools/test-native-ship-models.mjs +176 -0
- package/tools/test-native-ship-walkthrough.mjs +96 -0
- package/tools/test-native-slash-commands.mjs +125 -0
- package/tools/test-native-slash-commands.py +242 -0
- package/tools/test-native-swe-host.mjs +224 -55
- package/tools/test-native-tasks.mjs +77 -0
- package/tools/test-native-terminal-performance.py +887 -0
- package/tools/test-native-terminal-readiness.sh +85 -0
- package/tools/test-native-terminal-semantics-installed.mjs +38 -0
- package/tools/test-native-visual-clear-installed.mjs +48 -0
- package/tools/test-owned-host-controls.mjs +238 -0
- package/tools/test-owned-opencode-runtime.mjs +35 -0
- package/tools/test-owned-visual-clear-env.py +120 -0
- package/tools/test-owned-visual-clear.py +373 -0
- package/tools/verify-draft-runtime-gate.mjs +138 -0
- package/tools/verify-owned-runtime-release.mjs +114 -0
- package/tools/verify-release-version.mjs +21 -1
|
@@ -0,0 +1,1312 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { basename, dirname, join } from 'node:path';
|
|
4
|
+
import { cliHelp, openCodeCliManifest, parseOpenCodeArgs, runOpenCodeCli } from '../lib/opencode/cli.mjs';
|
|
5
|
+
import { createOpenCodeCliRuntime as createRuntime, headlessSucceeded, latestSessionForWorkspace, modelWithThinking, nativeJsonEvents, reconcileHeadlessResult, textParts } from '../lib/opencode/cli-runtime.mjs';
|
|
6
|
+
|
|
7
|
+
// These are offline fixtures, including when they simulate an interactive TTY.
|
|
8
|
+
// Never let a fixture URL reach the user's operating-system browser launcher.
|
|
9
|
+
const createOpenCodeCliRuntime = options => createRuntime({ openBrowserImpl: async () => false, ...options });
|
|
10
|
+
import { runNativeUpdate } from '../lib/opencode/update.mjs';
|
|
11
|
+
|
|
12
|
+
function ioCapture() {
|
|
13
|
+
const output = { stdout: [], stderr: [] };
|
|
14
|
+
return { output, io: { log: value => output.stdout.push(String(value)), error: value => output.stderr.push(String(value)) } };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
test('native CLI parser preserves supported options, aliases and prompt delimiter', () => {
|
|
18
|
+
assert.deepEqual(parseOpenCodeArgs([
|
|
19
|
+
'--provider', 'openai', '--model', 'o4-mini', '--engine-cli', '/opt/opencode',
|
|
20
|
+
'--isolate', '--no-strict', '--json', '--', 'Build', 'it', '--literally',
|
|
21
|
+
]), {
|
|
22
|
+
provider: 'openai', model: 'o4-mini', engineCli: '/opt/opencode', isolate: true,
|
|
23
|
+
strict: false, json: true, prompt: 'Build it --literally',
|
|
24
|
+
});
|
|
25
|
+
assert.equal(openCodeCliManifest.aliases.ui, 'tui');
|
|
26
|
+
assert.equal(openCodeCliManifest.aliases.s, 'status');
|
|
27
|
+
assert.throws(() => parseOpenCodeArgs(['--model']), /Missing value/);
|
|
28
|
+
assert.throws(() => parseOpenCodeArgs(['--wat']), /Unknown flag/);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('native CLI selects headless execution and preserves output-mode options', async () => {
|
|
32
|
+
const { output, io } = ioCapture();
|
|
33
|
+
let invocation;
|
|
34
|
+
const code = await runOpenCodeCli({
|
|
35
|
+
argv: ['-p', '--model', 'openai/gpt-4.1', 'Do work'], stdoutIsTTY: false, io,
|
|
36
|
+
runtime: { run: async request => { invocation = request; return 7; } },
|
|
37
|
+
});
|
|
38
|
+
assert.equal(code, 7);
|
|
39
|
+
assert.deepEqual(output.stderr, []);
|
|
40
|
+
assert.equal(invocation.mode, 'headless');
|
|
41
|
+
assert.equal(invocation.options.model, 'openai/gpt-4.1');
|
|
42
|
+
assert.equal(invocation.prompt, 'Do work');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('headless CLI validates prompts and maps cancellation to conventional exit code', async () => {
|
|
46
|
+
const { io, output } = ioCapture();
|
|
47
|
+
assert.equal(await runOpenCodeCli({ argv: [], stdoutIsTTY: false, io, runtime: {} }), 1);
|
|
48
|
+
assert.match(output.stderr[0], /requires a prompt/);
|
|
49
|
+
assert.equal(await runOpenCodeCli({
|
|
50
|
+
argv: ['task'], stdoutIsTTY: true, io,
|
|
51
|
+
runtime: { run: async () => { const error = new Error('cancelled'); error.name = 'AbortError'; throw error; } },
|
|
52
|
+
}), 130);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('model, auth, cloud and domain entrypoints dispatch to injected native capabilities', async () => {
|
|
56
|
+
const calls = [];
|
|
57
|
+
const runtime = {
|
|
58
|
+
listModels: async request => { calls.push(['models', request.query]); },
|
|
59
|
+
login: async () => { calls.push(['login']); },
|
|
60
|
+
account: async () => { calls.push(['account']); },
|
|
61
|
+
billing: async request => { calls.push(['billing', request.action]); },
|
|
62
|
+
logout: async request => { calls.push(['logout', request.session]); },
|
|
63
|
+
domain: async request => { calls.push(['domain', request.command, request.args[0]]); return 0; },
|
|
64
|
+
};
|
|
65
|
+
const { io } = ioCapture();
|
|
66
|
+
assert.equal(await runOpenCodeCli({ argv: ['--list-models', 'claude'], runtime, io }), 0);
|
|
67
|
+
assert.equal(await runOpenCodeCli({ argv: ['login'], runtime, io, stdoutIsTTY: true }), 0);
|
|
68
|
+
assert.equal(await runOpenCodeCli({ argv: ['auth'], runtime, io, stdoutIsTTY: true }), 0);
|
|
69
|
+
assert.equal(await runOpenCodeCli({ argv: ['account'], runtime, io }), 0);
|
|
70
|
+
assert.equal(await runOpenCodeCli({ argv: ['cloud'], runtime, io }), 0);
|
|
71
|
+
assert.equal(await runOpenCodeCli({ argv: ['billing'], runtime, io }), 0);
|
|
72
|
+
assert.equal(await runOpenCodeCli({ argv: ['logout-cloud', 'session-1'], runtime, io }), 0);
|
|
73
|
+
assert.equal(await runOpenCodeCli({ argv: ['check', 'unit'], runtime, io }), 0);
|
|
74
|
+
assert.deepEqual(calls, [
|
|
75
|
+
['models', 'claude'], ['login'], ['login'], ['account'], ['account'], ['billing', 'checkout'],
|
|
76
|
+
['logout', 'session-1'], ['domain', 'check', 'unit'],
|
|
77
|
+
]);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('help preserves command aliases, Ship operations and isolation semantics', async () => {
|
|
81
|
+
const { output, io } = ioCapture();
|
|
82
|
+
assert.equal(await runOpenCodeCli({ argv: ['--help'], io, version: '1.0.3' }), 0);
|
|
83
|
+
assert.match(output.stdout[0], /account.*Ship credit/);
|
|
84
|
+
assert.match(output.stdout[0], /billing.*checkout\|portal/);
|
|
85
|
+
assert.match(output.stdout[0], /--isolate\s+Isolate extension\/skill discovery \(not a worktree\)/);
|
|
86
|
+
assert.equal(cliHelp('x').includes('--engine-cli, --agent-cli'), true);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('missing native capabilities fail explicitly instead of silently succeeding', async () => {
|
|
90
|
+
const { output, io } = ioCapture();
|
|
91
|
+
assert.equal(await runOpenCodeCli({ argv: ['account'], runtime: {}, io }), 1);
|
|
92
|
+
assert.match(output.stderr[0], /does not provide account\(\)/);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('headless native results require authoritative successful terminal and assistant outcomes', () => {
|
|
96
|
+
assert.equal(headlessSucceeded({ messages: [] }), false);
|
|
97
|
+
assert.equal(headlessSucceeded({ messages: [{ type: 'assistant', finish: 'stop', parts: [{ type: 'text', text: 'looks done' }] }] }), false);
|
|
98
|
+
assert.equal(headlessSucceeded({ messages: [
|
|
99
|
+
{ type: 'assistant', finish: 'error', error: { message: 'provider failed' } },
|
|
100
|
+
{ type: 'idle', outcome: 'failed' },
|
|
101
|
+
] }), false);
|
|
102
|
+
assert.equal(headlessSucceeded({ session: { outcome: 'interrupted' }, messages: [
|
|
103
|
+
{ type: 'assistant', finish: 'stop' }, { type: 'idle', outcome: 'interrupted' },
|
|
104
|
+
] }), false);
|
|
105
|
+
assert.equal(headlessSucceeded({ session: { outcome: 'succeeded' }, messages: [
|
|
106
|
+
{ type: 'assistant', finish: 'stop' }, { type: 'idle', outcome: 'succeeded' },
|
|
107
|
+
] }), true);
|
|
108
|
+
assert.equal(headlessSucceeded({ session: { outcome: 'failed' }, messages: [
|
|
109
|
+
{ type: 'assistant', finish: 'stop' }, { type: 'idle', outcome: 'succeeded' },
|
|
110
|
+
] }), false);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('thinking level maps to native model variant, not an invented prompt property', () => {
|
|
114
|
+
assert.deepEqual(modelWithThinking({ providerID: 'fixture', id: 'model' }, 'high'), {
|
|
115
|
+
providerID: 'fixture', id: 'model', variant: 'high',
|
|
116
|
+
});
|
|
117
|
+
assert.equal(modelWithThinking(undefined, 'high'), undefined);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test('headless terminal reconciliation preserves the bounded turn and observes missing terminal state', async () => {
|
|
121
|
+
let reads = 0;
|
|
122
|
+
const latest = await reconcileHeadlessResult({
|
|
123
|
+
session: { id: 'ses_lagging' },
|
|
124
|
+
accepted: { id: 'accepted-user' },
|
|
125
|
+
messages: [{ type: 'user', id: 'accepted-user' }],
|
|
126
|
+
}, { timeoutMs: 500, client: { session: {
|
|
127
|
+
get: async () => ({ id: 'ses_lagging', ...(++reads < 2 ? {} : { outcome: 'succeeded' }) }),
|
|
128
|
+
context: async () => {
|
|
129
|
+
reads++;
|
|
130
|
+
return [
|
|
131
|
+
{ type: 'user', id: 'older-user' }, { type: 'assistant', finish: 'stop', parts: [{ type: 'text', text: 'stale answer' }] },
|
|
132
|
+
{ type: 'user', id: 'accepted-user' },
|
|
133
|
+
...(reads < 2 ? [] : [{ type: 'assistant', finish: 'stop', parts: [{ type: 'text', text: 'answer' }] }, { type: 'idle', outcome: 'succeeded' }]),
|
|
134
|
+
];
|
|
135
|
+
},
|
|
136
|
+
} } });
|
|
137
|
+
assert.ok(reads >= 2);
|
|
138
|
+
assert.equal(headlessSucceeded(latest), true);
|
|
139
|
+
assert.deepEqual(textParts(latest.messages), ['answer']);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test('accepted nonterminal headless result keeps current-turn JSON events and session identity without history lookup', async t => {
|
|
143
|
+
const { mkdtempSync, rmSync } = await import('node:fs');
|
|
144
|
+
const { tmpdir } = await import('node:os');
|
|
145
|
+
const { join } = await import('node:path');
|
|
146
|
+
const cwd = mkdtempSync(join(tmpdir(), 'ctg-opencode-accepted-nonterminal-'));
|
|
147
|
+
t.after(() => rmSync(cwd, { recursive: true, force: true }));
|
|
148
|
+
const lines = [];
|
|
149
|
+
let contextReads = 0;
|
|
150
|
+
const runtime = await createOpenCodeCliRuntime({ cwd, stdout: { write: value => lines.push(value) }, io: ioCapture().io, native: {
|
|
151
|
+
runtime: {}, service: { ensureService: async () => ({ url: 'http://127.0.0.1:1', headers: {} }) },
|
|
152
|
+
headless: { runHeadless: async () => ({
|
|
153
|
+
session: { id: 'ses_current' }, accepted: { id: 'user-current' }, outcome: 'timed-out', terminal: false,
|
|
154
|
+
messages: [
|
|
155
|
+
{ type: 'user', id: 'user-current' }, { type: 'assistant', finish: 'tool-calls', parts: [] },
|
|
156
|
+
],
|
|
157
|
+
}) },
|
|
158
|
+
clientFactory: async () => ({
|
|
159
|
+
event: { subscribe: () => ({ async *[Symbol.asyncIterator]() {
|
|
160
|
+
yield { id: 'call', created: 1, type: 'session.tool.called', data: { sessionID: 'ses_current', id: 'tool-1', input: { name: 'read', arguments: { filePath: 'a.txt' } } } };
|
|
161
|
+
yield { id: 'done', created: 2, type: 'session.tool.success', data: { sessionID: 'ses_current', id: 'tool-1', content: [{ type: 'text', text: 'contents' }] } };
|
|
162
|
+
} }) },
|
|
163
|
+
session: { context: async () => { contextReads++; throw new Error('must not reconcile nonterminal result'); } },
|
|
164
|
+
}),
|
|
165
|
+
} });
|
|
166
|
+
assert.equal(await runtime.run({ mode: 'headless', options: { prompt: 'inspect', json: true }, prompt: 'inspect' }), 1);
|
|
167
|
+
assert.equal(contextReads, 0);
|
|
168
|
+
const events = lines.map(line => JSON.parse(line));
|
|
169
|
+
assert.ok(events.some(event => event.type === 'tool_execution_start' && event.sessionID === 'ses_current'));
|
|
170
|
+
assert.ok(events.some(event => event.type === 'tool_execution_end' && event.sessionID === 'ses_current'));
|
|
171
|
+
assert.ok(!events.some(event => event.message?.type === 'idle' && event.message.outcome === 'succeeded'));
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test('headless reconciliation stops on abort or timeout and never swaps in stale history', async () => {
|
|
175
|
+
const result = { session: { id: 'ses_blocked' }, messages: [
|
|
176
|
+
{ type: 'user', id: 'accepted' }, { type: 'assistant', finish: 'error', errorMessage: 'provider failed', content: [] },
|
|
177
|
+
], accepted: { id: 'accepted' }, outcome: 'failed', terminal: true, error: 'provider failed' };
|
|
178
|
+
const controller = new AbortController();
|
|
179
|
+
controller.abort(new Error('cancelled'));
|
|
180
|
+
const aborted = await reconcileHeadlessResult(result, { signal: controller.signal, client: { session: {
|
|
181
|
+
context: async () => { throw new Error('must not request after abort'); },
|
|
182
|
+
} } });
|
|
183
|
+
assert.equal(headlessSucceeded(aborted), false);
|
|
184
|
+
assert.equal(aborted.error, 'provider failed');
|
|
185
|
+
assert.deepEqual(textParts(aborted.messages), []);
|
|
186
|
+
|
|
187
|
+
const started = Date.now();
|
|
188
|
+
const bounded = await reconcileHeadlessResult({ ...result, terminal: false }, { timeoutMs: 30, client: { session: {
|
|
189
|
+
context: async () => new Promise(() => {}),
|
|
190
|
+
} } });
|
|
191
|
+
assert.ok(Date.now() - started < 500);
|
|
192
|
+
assert.equal(bounded.terminal, false);
|
|
193
|
+
assert.equal(headlessSucceeded(bounded), false);
|
|
194
|
+
assert.deepEqual(textParts(bounded.messages), []);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test('a provider failure after a previous successful assistant turn cannot print stale success', async t => {
|
|
198
|
+
const { mkdtempSync, rmSync } = await import('node:fs');
|
|
199
|
+
const { tmpdir } = await import('node:os');
|
|
200
|
+
const { join } = await import('node:path');
|
|
201
|
+
const cwd = mkdtempSync(join(tmpdir(), 'ctg-opencode-stale-turn-'));
|
|
202
|
+
t.after(() => rmSync(cwd, { recursive: true, force: true }));
|
|
203
|
+
const output = [];
|
|
204
|
+
const stdout = { write: text => output.push(String(text)) };
|
|
205
|
+
const runtime = await createOpenCodeCliRuntime({ cwd, stdout, io: ioCapture().io, native: {
|
|
206
|
+
runtime: {}, service: { ensureService: async () => ({ url: 'http://127.0.0.1:1', headers: {} }) },
|
|
207
|
+
headless: { runHeadless: async () => ({
|
|
208
|
+
session: { id: 'ses_old_success' }, accepted: { id: 'new-user' }, outcome: 'failed', terminal: true, error: 'provider unavailable',
|
|
209
|
+
messages: [
|
|
210
|
+
{ type: 'user', id: 'old-user' }, { type: 'assistant', finish: 'stop', parts: [{ type: 'text', text: 'stale success answer' }] }, { type: 'idle', outcome: 'succeeded' },
|
|
211
|
+
{ type: 'user', id: 'new-user' }, { type: 'assistant', finish: 'error', errorMessage: 'provider unavailable', parts: [] },
|
|
212
|
+
],
|
|
213
|
+
}) },
|
|
214
|
+
clientFactory: async () => ({ session: {
|
|
215
|
+
context: async () => [
|
|
216
|
+
{ type: 'user', id: 'old-user' }, { type: 'assistant', finish: 'stop', parts: [{ type: 'text', text: 'stale success answer' }] }, { type: 'idle', outcome: 'succeeded' },
|
|
217
|
+
],
|
|
218
|
+
} }),
|
|
219
|
+
} });
|
|
220
|
+
assert.equal(await runtime.run({ mode: 'headless', options: { prompt: 'new turn' }, prompt: 'new turn' }), 1);
|
|
221
|
+
assert.deepEqual(output, []);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test('headless text output selects the completed assistant response for the latest user turn', () => {
|
|
225
|
+
assert.deepEqual(textParts([
|
|
226
|
+
{ type: 'user', text: 'old question' },
|
|
227
|
+
{ type: 'assistant', finish: 'stop', parts: [{ type: 'text', text: 'stale answer' }] },
|
|
228
|
+
{ type: 'idle', outcome: 'succeeded' },
|
|
229
|
+
{ type: 'user', text: 'new question' },
|
|
230
|
+
{ type: 'assistant', finish: 'stop', parts: [{ type: 'text', text: 'current answer' }] },
|
|
231
|
+
{ type: 'idle', outcome: 'succeeded' },
|
|
232
|
+
]), ['current answer']);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test('native JSON event projection translates real tool lifecycle and filters unrelated sessions', () => {
|
|
236
|
+
const events = nativeJsonEvents([
|
|
237
|
+
{ id: 'foreign', created: 1, type: 'session.tool.called', data: { sessionID: 'other', id: 'x', input: { name: 'bash' } } },
|
|
238
|
+
{ id: 'called', created: 2, type: 'session.tool.called', data: { sessionID: 'selected', id: 'call-1', input: { name: 'bash', arguments: { command: 'printf ok' } } } },
|
|
239
|
+
{ id: 'success', created: 3, type: 'session.tool.success', data: { sessionID: 'selected', id: 'call-1', content: [{ type: 'text', text: 'ok' }] } },
|
|
240
|
+
], 'selected', [
|
|
241
|
+
{ type: 'user', text: 'run' },
|
|
242
|
+
{ type: 'assistant', finish: 'tool-calls', parts: [] },
|
|
243
|
+
{ type: 'tool', state: { status: 'completed' } },
|
|
244
|
+
{ type: 'assistant', finish: 'stop', parts: [{ type: 'text', text: 'done' }] },
|
|
245
|
+
{ type: 'idle', outcome: 'succeeded' },
|
|
246
|
+
]);
|
|
247
|
+
assert.deepEqual(events.map(event => event.type), ['tool_execution_start', 'tool_execution_end', 'message_end', 'message']);
|
|
248
|
+
assert.equal(events[0].toolName, 'bash');
|
|
249
|
+
assert.deepEqual(events[0].args, { command: 'printf ok' });
|
|
250
|
+
assert.equal(events[1].isError, false);
|
|
251
|
+
assert.deepEqual(events[2].message.content, [{ type: 'text', text: 'done' }]);
|
|
252
|
+
assert.deepEqual(events[3].message, { type: 'idle', outcome: 'succeeded' });
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
test('native JSON failure projection preserves provider error and terminal outcome for the selected session', () => {
|
|
256
|
+
const events = nativeJsonEvents([
|
|
257
|
+
{ id: 'stale-other-session', created: 1, type: 'session.execution.succeeded', data: { sessionID: 'another-session' } },
|
|
258
|
+
{ id: 'failed-execution', created: 2, type: 'session.execution.failed', data: { sessionID: 'failed-session', error: { message: 'provider unavailable' } } },
|
|
259
|
+
], 'failed-session', [
|
|
260
|
+
{ type: 'user', text: 'fail' },
|
|
261
|
+
{ type: 'assistant', finish: 'error', errorMessage: 'provider unavailable', content: [{ type: 'text', text: 'provider unavailable' }] },
|
|
262
|
+
{ type: 'idle', outcome: 'failed' },
|
|
263
|
+
]);
|
|
264
|
+
assert.deepEqual(events.map(event => event.type), ['message_end', 'message', 'message']);
|
|
265
|
+
assert.ok(events.every(event => event.sessionID === 'failed-session'));
|
|
266
|
+
assert.equal(events[0].message.stopReason, 'error');
|
|
267
|
+
assert.equal(events[0].message.error, 'provider unavailable');
|
|
268
|
+
assert.deepEqual(events.at(-1).message, { type: 'idle', outcome: 'failed' });
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
test('native JSON projection uses authoritative headless outcome when admission failed before a new turn', async t => {
|
|
272
|
+
const { mkdtempSync, rmSync } = await import('node:fs');
|
|
273
|
+
const { tmpdir } = await import('node:os');
|
|
274
|
+
const { join } = await import('node:path');
|
|
275
|
+
const cwd = mkdtempSync(join(tmpdir(), 'ctg-opencode-pre-admission-'));
|
|
276
|
+
t.after(() => rmSync(cwd, { recursive: true, force: true }));
|
|
277
|
+
const lines = [];
|
|
278
|
+
let contextReads = 0;
|
|
279
|
+
const runtime = await createOpenCodeCliRuntime({ cwd, stdout: { write: value => lines.push(value) }, io: ioCapture().io, native: {
|
|
280
|
+
runtime: {}, service: { ensureService: async () => ({ url: 'http://127.0.0.1:1', headers: {} }) },
|
|
281
|
+
headless: { runHeadless: async () => ({ session: { id: 'ses_existing' }, messages: [], outcome: 'interrupted', terminal: false, reason: 'aborted' }) },
|
|
282
|
+
clientFactory: async () => ({ event: { subscribe: () => ({ async *[Symbol.asyncIterator]() { } }) }, session: {
|
|
283
|
+
get: async () => { throw new Error('must not query existing session after pre-admission failure'); },
|
|
284
|
+
context: async () => { contextReads++; throw new Error('must not query existing context after pre-admission failure'); },
|
|
285
|
+
} }),
|
|
286
|
+
} });
|
|
287
|
+
assert.equal(await runtime.run({ mode: 'headless', options: { prompt: 'new prompt', session: 'ses_existing', json: true }, prompt: 'new prompt' }), 1);
|
|
288
|
+
assert.equal(contextReads, 0);
|
|
289
|
+
const events = lines.map(line => JSON.parse(line));
|
|
290
|
+
assert.ok(!events.some(event => JSON.stringify(event).includes('old success')));
|
|
291
|
+
assert.ok(events.some(event => event.type === 'message' && event.message.type === 'idle' && event.message.outcome === 'interrupted'));
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
test('pre-admission interruption without a session ID emits JSON terminal status without listing old sessions', async t => {
|
|
295
|
+
const { mkdtempSync, rmSync } = await import('node:fs');
|
|
296
|
+
const { tmpdir } = await import('node:os');
|
|
297
|
+
const { join } = await import('node:path');
|
|
298
|
+
const cwd = mkdtempSync(join(tmpdir(), 'ctg-opencode-pre-admission-no-session-'));
|
|
299
|
+
t.after(() => rmSync(cwd, { recursive: true, force: true }));
|
|
300
|
+
const lines = [];
|
|
301
|
+
let listed = false;
|
|
302
|
+
const runtime = await createOpenCodeCliRuntime({ cwd, stdout: { write: value => lines.push(value) }, io: ioCapture().io, native: {
|
|
303
|
+
runtime: {}, service: { ensureService: async () => ({ url: 'http://127.0.0.1:1', headers: {} }) },
|
|
304
|
+
headless: { runHeadless: async () => ({ messages: [], outcome: 'interrupted', terminal: false, reason: 'aborted' }) },
|
|
305
|
+
clientFactory: async () => ({
|
|
306
|
+
event: { subscribe: () => ({ async *[Symbol.asyncIterator]() { } }) },
|
|
307
|
+
session: { list: async () => { listed = true; return { data: [{ id: 'previous', location: { directory: cwd } }] }; } },
|
|
308
|
+
}),
|
|
309
|
+
} });
|
|
310
|
+
assert.equal(await runtime.run({ mode: 'headless', options: { prompt: 'new prompt', json: true }, prompt: 'new prompt' }), 1);
|
|
311
|
+
assert.equal(listed, false);
|
|
312
|
+
const events = lines.map(line => JSON.parse(line));
|
|
313
|
+
assert.deepEqual(events, [{ type: 'message', message: { type: 'idle', outcome: 'interrupted' } }]);
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
test('headless continuation selects the newest session within the exact workspace', async () => {
|
|
317
|
+
const selected = await latestSessionForWorkspace({ session: { list: async ({ directory }) => ({ data: [
|
|
318
|
+
{ id: 'wrong-workspace-newest', location: { directory: '/other' }, time: { updated: 999 } },
|
|
319
|
+
{ id: 'older-workspace-session', location: { directory }, time: { updated: 10 } },
|
|
320
|
+
{ id: 'newest-workspace-session', location: { directory }, time: { updated: 20 } },
|
|
321
|
+
] }) } }, '/project');
|
|
322
|
+
assert.equal(selected, 'newest-workspace-session');
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
test('native runtime fails closed when the active plugin does not advertise delivery budget enforcement', async () => {
|
|
326
|
+
const runtime = await createOpenCodeCliRuntime({ cwd: process.cwd(), native: {
|
|
327
|
+
runtime: {}, service: { ensureService: async () => ({ url: 'http://127.0.0.1:1', headers: {} }) },
|
|
328
|
+
headless: {}, rpcDefinitions: { Carthagent: {} },
|
|
329
|
+
clientFactory: async () => ({ rpc: () => ({ health: async () => ({ capabilities: [] }) }) }),
|
|
330
|
+
} });
|
|
331
|
+
for (const [key, value] of [['maxTools', '2'], ['maxSeconds', '20'], ['maxRepairs', '1']]) {
|
|
332
|
+
await assert.rejects(runtime.run({ mode: 'headless', options: { prompt: 'task', [key]: value }, prompt: 'task' }), /does not advertise required enforcement capabilities/);
|
|
333
|
+
}
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
test('native bash-cap requires session-option enforcement and is sent in per-session metadata', async t => {
|
|
337
|
+
const { mkdtempSync, rmSync } = await import('node:fs');
|
|
338
|
+
const { tmpdir } = await import('node:os');
|
|
339
|
+
const { join } = await import('node:path');
|
|
340
|
+
const cwd = mkdtempSync(join(tmpdir(), 'ctg-bash-cap-'));
|
|
341
|
+
t.after(() => rmSync(cwd, { recursive: true, force: true }));
|
|
342
|
+
let saved;
|
|
343
|
+
const runtime = await createOpenCodeCliRuntime({ cwd, stdout: { write() {} }, native: {
|
|
344
|
+
runtime: {}, service: { ensureService: async () => ({ url: 'http://127.0.0.1:1', headers: {} }) },
|
|
345
|
+
rpcDefinitions: { Carthagent: {} },
|
|
346
|
+
headless: { runHeadless: async (_service, _cwd, _prompt, options) => {
|
|
347
|
+
saved = options.session.metadata['carthagent.options'];
|
|
348
|
+
return { session: { id: 'ses_bash_cap' }, outcome: 'succeeded', terminal: true, accepted: { id: 'a' }, messages: [
|
|
349
|
+
{ type: 'user', id: 'a' }, { type: 'assistant', finish: 'stop', parts: [{ type: 'text', text: 'ok' }] }, { type: 'idle', outcome: 'succeeded' },
|
|
350
|
+
] };
|
|
351
|
+
} },
|
|
352
|
+
clientFactory: async () => ({ rpc: () => ({ health: async () => ({ capabilities: ['delivery.session-options.v1'] }) }), session: {
|
|
353
|
+
get: async () => ({ id: 'ses_bash_cap', outcome: 'succeeded' }),
|
|
354
|
+
context: async () => [{ type: 'user', id: 'a' }, { type: 'assistant', finish: 'stop' }, { type: 'idle', outcome: 'succeeded' }],
|
|
355
|
+
} }),
|
|
356
|
+
} });
|
|
357
|
+
assert.equal(await runtime.run({ mode: 'headless', options: { prompt: 'task', bashCap: '12.5', protectExisting: true, rewriteCap: '2', toolOutputCap: '4000', turnDelayMs: '50', jev: false, jevProvider: 'ship' }, prompt: 'task' }), 0);
|
|
358
|
+
assert.equal(saved.bashCap, 12.5);
|
|
359
|
+
assert.deepEqual({ protectExisting: saved.protectExisting, rewriteCap: saved.rewriteCap, toolOutputCap: saved.toolOutputCap, turnDelayMs: saved.turnDelayMs, jev: saved.jev, jevProvider: saved.jevProvider }, { protectExisting: true, rewriteCap: 2, toolOutputCap: 4000, turnDelayMs: 50, jev: false, jevProvider: 'ship' });
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
test('native run rejects only options without an enforcement contract and persists framing metadata', async t => {
|
|
363
|
+
const { mkdtempSync, rmSync } = await import('node:fs');
|
|
364
|
+
const { tmpdir } = await import('node:os');
|
|
365
|
+
const { join } = await import('node:path');
|
|
366
|
+
const cwd = mkdtempSync(join(tmpdir(), 'ctg-session-options-'));
|
|
367
|
+
t.after(() => rmSync(cwd, { recursive: true, force: true }));
|
|
368
|
+
const service = { url: 'http://127.0.0.1:1', headers: {} };
|
|
369
|
+
const sessions = [];
|
|
370
|
+
const runtime = await createOpenCodeCliRuntime({ cwd, native: {
|
|
371
|
+
runtime: {}, service: { ensureService: async () => service },
|
|
372
|
+
rpcDefinitions: { Carthagent: {}, SessionControls: {} },
|
|
373
|
+
headless: { runHeadless: async (_service, _cwd, _prompt, options) => {
|
|
374
|
+
sessions.push(options.session.metadata);
|
|
375
|
+
return { session: { id: 'ses_options' }, outcome: 'succeeded', terminal: true, accepted: { id: 'a' }, messages: [
|
|
376
|
+
{ type: 'user', id: 'a' }, { type: 'assistant', finish: 'stop', parts: [{ type: 'text', text: 'ok' }] }, { type: 'idle', outcome: 'succeeded' },
|
|
377
|
+
] };
|
|
378
|
+
} },
|
|
379
|
+
clientFactory: async () => ({ rpc: () => ({ health: async () => ({ capabilities: ['delivery.session-options.v1'] }) }), session: { get: async () => ({ id: 'ses_options', outcome: 'succeeded' }), context: async () => [
|
|
380
|
+
{ type: 'user', id: 'a' }, { type: 'assistant', finish: 'stop', parts: [{ type: 'text', text: 'ok' }] }, { type: 'idle', outcome: 'succeeded' },
|
|
381
|
+
] } }),
|
|
382
|
+
} });
|
|
383
|
+
await assert.rejects(runtime.run({ mode: 'headless', options: { prompt: 'task', maxTools: '2' }, prompt: 'task' }), /does not advertise required enforcement capabilities/);
|
|
384
|
+
await assert.rejects(runtime.run({ mode: 'headless', options: { prompt: 'task', delivery: false }, prompt: 'task' }), /does not advertise required enforcement capabilities/);
|
|
385
|
+
await assert.rejects(runtime.run({ mode: 'headless', options: { prompt: 'task', strict: false }, prompt: 'task' }), /does not advertise required enforcement capabilities/);
|
|
386
|
+
assert.equal(await runtime.run({ mode: 'headless', options: { prompt: 'task', autoFraming: false }, prompt: 'task' }), 0);
|
|
387
|
+
assert.deepEqual(sessions[0]['carthagent.options'], { guide: false });
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
test('native run resolves and persists the enforced codegraph index cap per session', async t => {
|
|
391
|
+
const { mkdtempSync, rmSync } = await import('node:fs');
|
|
392
|
+
const { tmpdir } = await import('node:os');
|
|
393
|
+
const { join } = await import('node:path');
|
|
394
|
+
const cwd = mkdtempSync(join(tmpdir(), 'ctg-codegraph-option-'));
|
|
395
|
+
t.after(() => rmSync(cwd, { recursive: true, force: true }));
|
|
396
|
+
let settings;
|
|
397
|
+
const runtime = await createOpenCodeCliRuntime({ cwd, stdout: { write() {} }, native: {
|
|
398
|
+
runtime: {}, service: { ensureService: async () => ({ url: 'http://127.0.0.1:1', headers: {} }) },
|
|
399
|
+
rpcDefinitions: { Carthagent: {}, SessionControls: {} },
|
|
400
|
+
headless: { runHeadless: async (_service, _cwd, _prompt, options) => {
|
|
401
|
+
settings = options.session.metadata['carthagent.options'];
|
|
402
|
+
return { session: { id: 'ses_codegraph' }, outcome: 'succeeded', terminal: true, accepted: { id: 'a' }, messages: [
|
|
403
|
+
{ type: 'user', id: 'a' }, { type: 'assistant', finish: 'stop', parts: [{ type: 'text', text: 'ok' }] }, { type: 'idle', outcome: 'succeeded' },
|
|
404
|
+
] };
|
|
405
|
+
} },
|
|
406
|
+
clientFactory: async () => ({ rpc: () => ({ health: async () => ({ capabilities: ['delivery.session-options.v1'] }) }), session: { get: async () => ({ id: 'ses_codegraph', outcome: 'succeeded' }), context: async () => [
|
|
407
|
+
{ type: 'user', id: 'a' }, { type: 'assistant', finish: 'stop', parts: [{ type: 'text', text: 'ok' }] }, { type: 'idle', outcome: 'succeeded' },
|
|
408
|
+
] } }),
|
|
409
|
+
} });
|
|
410
|
+
assert.equal(await runtime.run({ mode: 'headless', options: { prompt: 'index', codegraphMaxFiles: '300' }, prompt: 'index' }), 0);
|
|
411
|
+
assert.equal(settings.codegraphMaxFiles, 300);
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
test('interactive native session persists an explicit no-guide setting before launching the TUI', async t => {
|
|
415
|
+
const { mkdtempSync, rmSync } = await import('node:fs');
|
|
416
|
+
const { tmpdir } = await import('node:os');
|
|
417
|
+
const { join } = await import('node:path');
|
|
418
|
+
const cwd = mkdtempSync(join(tmpdir(), 'ctg-interactive-no-guide-'));
|
|
419
|
+
t.after(() => rmSync(cwd, { recursive: true, force: true }));
|
|
420
|
+
const calls = [];
|
|
421
|
+
const runtime = await createOpenCodeCliRuntime({ cwd, stdout: { write() {} }, native: {
|
|
422
|
+
runtime: {}, service: { ensureService: async () => ({ url: 'http://127.0.0.1:1', headers: {} }), launchTui: (_service, _cwd, options) => { calls.push(['launch', options.sessionID]); return { once(event, callback) { if (event === 'exit') queueMicrotask(() => callback(0)); return this; }, kill() {} }; } },
|
|
423
|
+
rpcDefinitions: { Carthagent: {}, SessionControls: {} },
|
|
424
|
+
headless: { createSession: async (_service, _cwd, { body }) => { calls.push(['create', body.metadata['carthagent.options']]); return { id: 'ses_no_guide' }; } },
|
|
425
|
+
clientFactory: async () => ({ rpc: () => ({ health: async () => ({ capabilities: ['delivery.session-options.v1'] }), framing: async input => { calls.push(['framing', input.sessionID, input.enabled]); return { enabled: input.enabled }; } }) }),
|
|
426
|
+
} });
|
|
427
|
+
assert.equal(await runtime.run({ mode: 'interactive', options: { autoFraming: false }, prompt: '' }), 0);
|
|
428
|
+
assert.deepEqual(calls, [
|
|
429
|
+
['create', { guide: false }],
|
|
430
|
+
['framing', 'ses_no_guide', false],
|
|
431
|
+
['launch', 'ses_no_guide'],
|
|
432
|
+
]);
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
test('no-prompt interactive launch persists review, bash cap, and budget options for new and resumed sessions', async t => {
|
|
436
|
+
const { mkdtempSync, rmSync } = await import('node:fs');
|
|
437
|
+
const { tmpdir } = await import('node:os');
|
|
438
|
+
const { join } = await import('node:path');
|
|
439
|
+
const cwd = mkdtempSync(join(tmpdir(), 'ctg-interactive-options-'));
|
|
440
|
+
t.after(() => rmSync(cwd, { recursive: true, force: true }));
|
|
441
|
+
const expected = { maxTools: 7, bashCap: 2.5, review: 'yes' };
|
|
442
|
+
const capabilities = ['delivery.session-options.v1', 'delivery.budgets.v1'];
|
|
443
|
+
|
|
444
|
+
for (const resumed of [false, true]) {
|
|
445
|
+
const calls = [];
|
|
446
|
+
const sessionID = resumed ? 'ses_existing' : 'ses_created';
|
|
447
|
+
const runtime = await createOpenCodeCliRuntime({ cwd, stdout: { write() {} }, native: {
|
|
448
|
+
runtime: {},
|
|
449
|
+
service: { ensureService: async () => ({ url: 'http://127.0.0.1:1', headers: {} }), launchTui: (_service, _cwd, options) => {
|
|
450
|
+
calls.push(['launch', options.sessionID]);
|
|
451
|
+
return { once(event, callback) { if (event === 'exit') queueMicrotask(() => callback(0)); return this; }, kill() {} };
|
|
452
|
+
} },
|
|
453
|
+
headless: { createSession: async (_service, _cwd, { body }) => {
|
|
454
|
+
calls.push(['create', body.metadata['carthagent.options']]);
|
|
455
|
+
return { id: sessionID };
|
|
456
|
+
} },
|
|
457
|
+
rpcDefinitions: { Carthagent: {} },
|
|
458
|
+
clientFactory: async () => ({
|
|
459
|
+
rpc: () => ({ health: async () => ({ capabilities }) }),
|
|
460
|
+
session: {
|
|
461
|
+
get: async () => ({ id: sessionID, location: { directory: cwd }, metadata: { keep: 'yes', 'carthagent.options': { workflow: 'strict' } } }),
|
|
462
|
+
update: async input => calls.push(['update', input.metadata]),
|
|
463
|
+
},
|
|
464
|
+
}),
|
|
465
|
+
} });
|
|
466
|
+
const options = { ...(resumed ? { session: sessionID } : {}), review: 'yes', bashCap: '2.5', maxTools: '7' };
|
|
467
|
+
assert.equal(await runtime.run({ mode: 'interactive', options, prompt: '' }), 0);
|
|
468
|
+
if (resumed) {
|
|
469
|
+
assert.deepEqual(calls[0], ['update', { keep: 'yes', 'carthagent.options': { workflow: 'strict', ...expected } }]);
|
|
470
|
+
assert.deepEqual(calls[1], ['launch', sessionID]);
|
|
471
|
+
} else {
|
|
472
|
+
assert.deepEqual(calls[0], ['create', expected]);
|
|
473
|
+
assert.deepEqual(calls[1], ['launch', sessionID]);
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
test('no-prompt interactive explicit model and thinking selection applies to fresh and resumed sessions before TUI launch', async t => {
|
|
479
|
+
const { mkdtempSync, rmSync } = await import('node:fs');
|
|
480
|
+
const { tmpdir } = await import('node:os');
|
|
481
|
+
const { join } = await import('node:path');
|
|
482
|
+
const cwd = mkdtempSync(join(tmpdir(), 'ctg-interactive-model-'));
|
|
483
|
+
t.after(() => rmSync(cwd, { recursive: true, force: true }));
|
|
484
|
+
const expectedModel = { providerID: 'fixture', id: 'explicit-model', variant: 'high' };
|
|
485
|
+
|
|
486
|
+
for (const resumed of [false, true]) {
|
|
487
|
+
const calls = [];
|
|
488
|
+
const sessionID = resumed ? 'ses_model_existing' : 'ses_model_created';
|
|
489
|
+
const runtime = await createOpenCodeCliRuntime({ cwd, stdout: { write() {} }, native: {
|
|
490
|
+
runtime: {},
|
|
491
|
+
service: {
|
|
492
|
+
ensureService: async () => ({ url: 'http://127.0.0.1:1', headers: {} }),
|
|
493
|
+
launchTui: (_service, _cwd, options) => {
|
|
494
|
+
calls.push(['launch', options.sessionID]);
|
|
495
|
+
return { once(event, callback) { if (event === 'exit') queueMicrotask(() => callback(0)); return this; }, kill() {} };
|
|
496
|
+
},
|
|
497
|
+
},
|
|
498
|
+
headless: { createSession: async (_service, _cwd, { body }) => {
|
|
499
|
+
calls.push(['create', body.model]);
|
|
500
|
+
return { id: sessionID };
|
|
501
|
+
} },
|
|
502
|
+
clientFactory: async () => ({ rpc: () => ({ health: async () => ({ capabilities: ['delivery.session-options.v1', 'delivery.budgets.v1'] }) }), session: {
|
|
503
|
+
switchModel: async input => calls.push(['switchModel', input]),
|
|
504
|
+
get: async () => ({ id: sessionID, location: { directory: cwd }, metadata: {} }),
|
|
505
|
+
} }),
|
|
506
|
+
} });
|
|
507
|
+
|
|
508
|
+
assert.equal(await runtime.run({
|
|
509
|
+
mode: 'interactive',
|
|
510
|
+
options: { ...(resumed ? { session: sessionID } : {}), provider: 'fixture', model: 'explicit-model', thinking: 'high' },
|
|
511
|
+
prompt: '',
|
|
512
|
+
}), 0);
|
|
513
|
+
if (resumed) {
|
|
514
|
+
assert.deepEqual(calls, [['switchModel', { sessionID, model: expectedModel }], ['launch', sessionID]]);
|
|
515
|
+
} else {
|
|
516
|
+
assert.deepEqual(calls, [['create', expectedModel], ['launch', sessionID]]);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
test('resumed interactive model selection verifies workspace ownership before any session mutation or launch', async t => {
|
|
522
|
+
const { mkdtempSync, rmSync } = await import('node:fs');
|
|
523
|
+
const { tmpdir } = await import('node:os');
|
|
524
|
+
const { join } = await import('node:path');
|
|
525
|
+
const cwd = mkdtempSync(join(tmpdir(), 'ctg-foreign-model-session-'));
|
|
526
|
+
t.after(() => rmSync(cwd, { recursive: true, force: true }));
|
|
527
|
+
const calls = [];
|
|
528
|
+
const runtime = await createOpenCodeCliRuntime({ cwd, stdout: { write() {} }, native: {
|
|
529
|
+
runtime: {},
|
|
530
|
+
service: {
|
|
531
|
+
ensureService: async () => ({ url: 'http://127.0.0.1:1', headers: {} }),
|
|
532
|
+
launchTui: () => { calls.push('launch'); return { once() { return this; }, kill() {} }; },
|
|
533
|
+
},
|
|
534
|
+
headless: {},
|
|
535
|
+
clientFactory: async () => ({ rpc: () => ({ health: async () => ({ capabilities: ['delivery.session-options.v1', 'delivery.budgets.v1'] }) }), session: {
|
|
536
|
+
get: async input => { calls.push(['get', input.sessionID]); return { id: input.sessionID, location: { directory: join(cwd, 'another-workspace') }, metadata: {} }; },
|
|
537
|
+
switchModel: async () => calls.push('switchModel'),
|
|
538
|
+
update: async () => calls.push('update'),
|
|
539
|
+
} }),
|
|
540
|
+
} });
|
|
541
|
+
await assert.rejects(runtime.run({
|
|
542
|
+
mode: 'interactive', options: { session: 'ses_foreign', model: 'fixture/explicit-model', maxTools: '3' }, prompt: '',
|
|
543
|
+
}), /another workspace/);
|
|
544
|
+
assert.deepEqual(calls, [['get', 'ses_foreign']], 'ownership is checked before switching models, updating metadata, or launching TUI');
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
test('interactive provider or thinking flags without a model fail instead of being silently ignored', async () => {
|
|
548
|
+
const { mkdtempSync, rmSync, writeFileSync } = await import('node:fs');
|
|
549
|
+
const { tmpdir } = await import('node:os');
|
|
550
|
+
const { join } = await import('node:path');
|
|
551
|
+
const root = mkdtempSync(join(tmpdir(), 'ctg-model-flags-no-default-'));
|
|
552
|
+
try {
|
|
553
|
+
const config = join(root, 'config.json');
|
|
554
|
+
writeFileSync(config, '{}');
|
|
555
|
+
const previousConfig = process.env.CARTHAGENT_CONFIG;
|
|
556
|
+
process.env.CARTHAGENT_CONFIG = config;
|
|
557
|
+
try {
|
|
558
|
+
for (const options of [{ provider: 'fixture' }, { thinking: 'high' }]) {
|
|
559
|
+
let touched = false;
|
|
560
|
+
const runtime = await createOpenCodeCliRuntime({ cwd: '/work/project', native: {
|
|
561
|
+
runtime: {}, service: { ensureService: async () => { touched = true; return {}; } }, headless: {},
|
|
562
|
+
} });
|
|
563
|
+
await assert.rejects(runtime.run({ mode: 'interactive', options, prompt: '' }), /require --model/);
|
|
564
|
+
assert.equal(touched, false);
|
|
565
|
+
}
|
|
566
|
+
} finally {
|
|
567
|
+
if (previousConfig === undefined) delete process.env.CARTHAGENT_CONFIG;
|
|
568
|
+
else process.env.CARTHAGENT_CONFIG = previousConfig;
|
|
569
|
+
}
|
|
570
|
+
} finally {
|
|
571
|
+
rmSync(root, { recursive: true, force: true });
|
|
572
|
+
}
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
test('interactive thinking-only selection resolves the configured default model and applies its variant', async t => {
|
|
576
|
+
const { mkdtempSync, rmSync, writeFileSync } = await import('node:fs');
|
|
577
|
+
const { tmpdir } = await import('node:os');
|
|
578
|
+
const { join } = await import('node:path');
|
|
579
|
+
const cwd = mkdtempSync(join(tmpdir(), 'ctg-thinking-default-model-'));
|
|
580
|
+
const config = join(cwd, 'config.json');
|
|
581
|
+
t.after(() => rmSync(cwd, { recursive: true, force: true }));
|
|
582
|
+
writeFileSync(config, JSON.stringify({ model: 'configured-provider/configured-model' }));
|
|
583
|
+
const previousConfig = process.env.CARTHAGENT_CONFIG;
|
|
584
|
+
process.env.CARTHAGENT_CONFIG = config;
|
|
585
|
+
let createdModel;
|
|
586
|
+
try {
|
|
587
|
+
const runtime = await createOpenCodeCliRuntime({ cwd, stdout: { write() {} }, native: {
|
|
588
|
+
runtime: {},
|
|
589
|
+
service: {
|
|
590
|
+
ensureService: async () => ({ url: 'http://127.0.0.1:1', headers: {} }),
|
|
591
|
+
launchTui: (_service, _cwd, options) => { assert.equal(options.sessionID, 'ses_default_thinking'); return { once(event, callback) { if (event === 'exit') queueMicrotask(() => callback(0)); return this; }, kill() {} }; },
|
|
592
|
+
},
|
|
593
|
+
headless: { createSession: async (_service, _cwd, { body }) => { createdModel = body.model; return { id: 'ses_default_thinking' }; } },
|
|
594
|
+
clientFactory: async () => ({ session: {} }),
|
|
595
|
+
} });
|
|
596
|
+
assert.equal(await runtime.run({ mode: 'interactive', options: { thinking: 'high' }, prompt: '' }), 0);
|
|
597
|
+
assert.deepEqual(createdModel, { providerID: 'configured-provider', id: 'configured-model', variant: 'high' });
|
|
598
|
+
} finally {
|
|
599
|
+
if (previousConfig === undefined) delete process.env.CARTHAGENT_CONFIG;
|
|
600
|
+
else process.env.CARTHAGENT_CONFIG = previousConfig;
|
|
601
|
+
}
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
test('qualified model with explicit provider uses the provider override and unqualified model ID', async t => {
|
|
605
|
+
const { mkdtempSync, rmSync } = await import('node:fs');
|
|
606
|
+
const { tmpdir } = await import('node:os');
|
|
607
|
+
const { join } = await import('node:path');
|
|
608
|
+
const cwd = mkdtempSync(join(tmpdir(), 'ctg-provider-model-override-'));
|
|
609
|
+
t.after(() => rmSync(cwd, { recursive: true, force: true }));
|
|
610
|
+
let createdModel;
|
|
611
|
+
const runtime = await createOpenCodeCliRuntime({ cwd, stdout: { write() {} }, native: {
|
|
612
|
+
runtime: {},
|
|
613
|
+
service: {
|
|
614
|
+
ensureService: async () => ({ url: 'http://127.0.0.1:1', headers: {} }),
|
|
615
|
+
launchTui: (_service, _cwd, options) => { assert.equal(options.sessionID, 'ses_provider_override'); return { once(event, callback) { if (event === 'exit') queueMicrotask(() => callback(0)); return this; }, kill() {} }; },
|
|
616
|
+
},
|
|
617
|
+
headless: { createSession: async (_service, _cwd, { body }) => { createdModel = body.model; return { id: 'ses_provider_override' }; } },
|
|
618
|
+
clientFactory: async () => ({ session: {} }),
|
|
619
|
+
} });
|
|
620
|
+
assert.equal(await runtime.run({ mode: 'interactive', options: { provider: 'override-provider', model: 'original-provider/model-id' }, prompt: '' }), 0);
|
|
621
|
+
assert.deepEqual(createdModel, { providerID: 'override-provider', id: 'model-id' });
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
test('resumed interactive validator launch merges required checks with existing session metadata before prompting', async t => {
|
|
625
|
+
const { mkdtempSync, rmSync, writeFileSync } = await import('node:fs');
|
|
626
|
+
const { tmpdir } = await import('node:os');
|
|
627
|
+
const { join } = await import('node:path');
|
|
628
|
+
const cwd = mkdtempSync(join(tmpdir(), 'ctg-resumed-validators-'));
|
|
629
|
+
t.after(() => rmSync(cwd, { recursive: true, force: true }));
|
|
630
|
+
const manifest = join(cwd, 'validators.json');
|
|
631
|
+
writeFileSync(manifest, JSON.stringify({ version: 1, checks: [{ id: 'unit', kind: 'test', argv: ['node', '--test'], timeoutSeconds: 20 }] }));
|
|
632
|
+
const originalMetadata = {
|
|
633
|
+
external: { owner: 'fixture', preserve: true },
|
|
634
|
+
'carthagent.options': { guide: false, maxTools: 9 },
|
|
635
|
+
'carthagent.other': { retained: true },
|
|
636
|
+
};
|
|
637
|
+
const updates = [];
|
|
638
|
+
const calls = [];
|
|
639
|
+
const sessionID = 'ses_validator_resume';
|
|
640
|
+
const runtime = await createOpenCodeCliRuntime({ cwd, stdout: { write() {} }, native: {
|
|
641
|
+
runtime: {},
|
|
642
|
+
service: {
|
|
643
|
+
ensureService: async () => ({ url: 'http://127.0.0.1:1', headers: {} }),
|
|
644
|
+
launchTui: (_service, _cwd, options) => {
|
|
645
|
+
calls.push(['launch', options.sessionID]);
|
|
646
|
+
return { once(event, callback) { if (event === 'exit') queueMicrotask(() => callback(0)); return this; }, kill() {} };
|
|
647
|
+
},
|
|
648
|
+
},
|
|
649
|
+
headless: { promptSession: async (_service, id, text, options) => calls.push(['prompt', id, text, options?.body]) },
|
|
650
|
+
rpcDefinitions: { Carthagent: {}, SessionControls: {} },
|
|
651
|
+
clientFactory: async () => ({
|
|
652
|
+
rpc: () => ({ health: async () => ({ capabilities: ['delivery.session-options.v1'] }), framing: async input => calls.push(['framing', input]) }),
|
|
653
|
+
session: {
|
|
654
|
+
get: async () => ({ id: sessionID, location: { directory: cwd }, metadata: originalMetadata }),
|
|
655
|
+
update: async input => updates.push(input),
|
|
656
|
+
},
|
|
657
|
+
}),
|
|
658
|
+
} });
|
|
659
|
+
assert.equal(await runtime.run({
|
|
660
|
+
mode: 'interactive', options: { session: sessionID, validators: 'validators.json' }, prompt: 'continue with the required checks',
|
|
661
|
+
}), 0);
|
|
662
|
+
|
|
663
|
+
assert.equal(updates.length, 1, 'persist required checks and launcher options in one merge update');
|
|
664
|
+
assert.equal(updates[0].sessionID, sessionID);
|
|
665
|
+
assert.deepEqual(updates[0].metadata.external, originalMetadata.external);
|
|
666
|
+
assert.deepEqual(updates[0].metadata['carthagent.other'], originalMetadata['carthagent.other']);
|
|
667
|
+
assert.deepEqual(updates[0].metadata['carthagent.options'], { guide: false, maxTools: 9, validators: manifest });
|
|
668
|
+
assert.deepEqual(updates[0].metadata['carthagent.requiredChecks'], [{ id: 'required_unit', kind: 'test', argv: ['node', '--test'], timeoutSeconds: 20 }]);
|
|
669
|
+
assert.deepEqual(calls.map(call => call[0]), ['prompt', 'prompt', 'launch']);
|
|
670
|
+
assert.match(calls[0][2], /Required user-owned validators/);
|
|
671
|
+
assert.equal(calls[2][1], sessionID);
|
|
672
|
+
});
|
|
673
|
+
|
|
674
|
+
test('native --isolate starts a clean CTG profile without inheriting the regular profile or creating a worktree', async () => {
|
|
675
|
+
const calls = [];
|
|
676
|
+
const runtime = await createOpenCodeCliRuntime({ cwd: '/work/project', stdout: { write() {} }, native: {
|
|
677
|
+
runtime: {
|
|
678
|
+
runtimePaths: options => ({ root: options.root ?? '/private/profile' }),
|
|
679
|
+
},
|
|
680
|
+
service: {
|
|
681
|
+
ensureService: async options => { calls.push(options); return { url: 'http://127.0.0.1:1', headers: {}, paths: { root: options.root } }; },
|
|
682
|
+
stopService: async options => { calls.push({ stopRoot: options.root }); return true; },
|
|
683
|
+
launchTui: (_service, workspace, options) => {
|
|
684
|
+
calls.push({ workspace, options });
|
|
685
|
+
return { once(event, callback) { if (event === 'exit') queueMicrotask(() => callback(0)); return this; }, kill() {} };
|
|
686
|
+
},
|
|
687
|
+
},
|
|
688
|
+
headless: {},
|
|
689
|
+
clientFactory: async () => ({ session: { create: async () => ({ id: 'ses_isolated' }) } }),
|
|
690
|
+
} });
|
|
691
|
+
assert.equal(await runtime.run({ mode: 'interactive', options: { isolate: true }, prompt: '' }), 0);
|
|
692
|
+
assert.equal(dirname(calls[0].root), join('/private/profile', 'isolated'));
|
|
693
|
+
assert.match(basename(calls[0].root), /^[0-9a-f-]+$/);
|
|
694
|
+
assert.equal(calls[0].isolate, true);
|
|
695
|
+
assert.match(calls[0].pluginPath, /plugins[\\/]carthagent$/);
|
|
696
|
+
assert.equal(calls[1].workspace, '/work/project');
|
|
697
|
+
assert.deepEqual(calls[2], { stopRoot: calls[0].root });
|
|
698
|
+
});
|
|
699
|
+
|
|
700
|
+
test('headless review modes are transported without inventing an unsupported interactive-only error', async t => {
|
|
701
|
+
const { mkdtempSync, rmSync } = await import('node:fs');
|
|
702
|
+
const { tmpdir } = await import('node:os');
|
|
703
|
+
const { join } = await import('node:path');
|
|
704
|
+
const cwd = mkdtempSync(join(tmpdir(), 'ctg-review-headless-'));
|
|
705
|
+
t.after(() => rmSync(cwd, { recursive: true, force: true }));
|
|
706
|
+
for (const review of ['ask', 'yes', 'no']) {
|
|
707
|
+
let captured;
|
|
708
|
+
const runtime = await createOpenCodeCliRuntime({ cwd, stdout: { write() {} }, io: { error() {}, log() {} }, native: {
|
|
709
|
+
runtime: {}, service: { ensureService: async () => ({ url: 'http://127.0.0.1:1', headers: {} }) },
|
|
710
|
+
headless: { runHeadless: async (_service, _cwd, _prompt, options) => {
|
|
711
|
+
captured = options.session.metadata['carthagent.options'];
|
|
712
|
+
return { session: { id: `ses_${review}`, outcome: 'succeeded' }, messages: [{ role: 'assistant', content: [{ type: 'text', text: 'done' }], finish: 'stop' }], outcome: 'succeeded', terminal: true };
|
|
713
|
+
} },
|
|
714
|
+
rpcDefinitions: { Carthagent: {} },
|
|
715
|
+
clientFactory: async () => ({
|
|
716
|
+
rpc: () => ({ health: async () => ({ capabilities: ['delivery.session-options.v1'] }) }),
|
|
717
|
+
}),
|
|
718
|
+
} });
|
|
719
|
+
assert.equal(await runtime.run({ mode: 'headless', options: { review, prompt: 'task' }, prompt: 'task' }), 0);
|
|
720
|
+
assert.equal(captured.review, review);
|
|
721
|
+
}
|
|
722
|
+
});
|
|
723
|
+
|
|
724
|
+
test('resumed native sessions merge explicit launcher options into their own workspace metadata', async t => {
|
|
725
|
+
const { mkdtempSync, rmSync } = await import('node:fs');
|
|
726
|
+
const { tmpdir } = await import('node:os');
|
|
727
|
+
const { join } = await import('node:path');
|
|
728
|
+
const cwd = mkdtempSync(join(tmpdir(), 'ctg-session-options-resume-'));
|
|
729
|
+
t.after(() => rmSync(cwd, { recursive: true, force: true }));
|
|
730
|
+
const updates = [];
|
|
731
|
+
const framingOverrides = [];
|
|
732
|
+
const runtime = await createOpenCodeCliRuntime({ cwd, stdout: { write() {} }, native: {
|
|
733
|
+
runtime: {}, service: { ensureService: async () => ({ url: 'http://127.0.0.1:1', headers: {} }) },
|
|
734
|
+
rpcDefinitions: { Carthagent: {}, SessionControls: {} },
|
|
735
|
+
headless: { runHeadless: async (_service, _cwd, _prompt, options) => {
|
|
736
|
+
assert.equal(options.sessionID, 'ses_existing');
|
|
737
|
+
return { session: { id: 'ses_existing' }, outcome: 'succeeded', terminal: true, accepted: { id: 'accepted' }, messages: [
|
|
738
|
+
{ type: 'user', id: 'accepted' }, { type: 'assistant', finish: 'stop', parts: [{ type: 'text', text: 'done' }] }, { type: 'idle', outcome: 'succeeded' },
|
|
739
|
+
] };
|
|
740
|
+
} },
|
|
741
|
+
clientFactory: async () => ({ rpc: () => ({ health: async () => ({ capabilities: ['delivery.session-options.v1'] }), framing: async input => { framingOverrides.push(input); return { enabled: input.enabled }; } }), session: {
|
|
742
|
+
get: async () => ({ id: 'ses_existing', location: { directory: cwd }, metadata: { keep: 'yes', 'carthagent.options': { maxTools: 12, workflow: 'strict' } } }),
|
|
743
|
+
update: async input => updates.push(input),
|
|
744
|
+
context: async () => [
|
|
745
|
+
{ type: 'user', id: 'accepted' }, { type: 'assistant', finish: 'stop', parts: [{ type: 'text', text: 'done' }] }, { type: 'idle', outcome: 'succeeded' },
|
|
746
|
+
],
|
|
747
|
+
} }),
|
|
748
|
+
} });
|
|
749
|
+
assert.equal(await runtime.run({ mode: 'headless', options: { prompt: 'continue', session: 'ses_existing', autoFraming: false }, prompt: 'continue' }), 0);
|
|
750
|
+
assert.equal(updates.length, 1);
|
|
751
|
+
assert.deepEqual(updates[0].metadata, { keep: 'yes', 'carthagent.options': { maxTools: 12, workflow: 'strict', guide: false } });
|
|
752
|
+
assert.deepEqual(framingOverrides, [{ sessionID: 'ses_existing', enabled: false }]);
|
|
753
|
+
});
|
|
754
|
+
|
|
755
|
+
test('native login uses OpenCode integration OAuth status and completion lifecycle', async () => {
|
|
756
|
+
const calls = [];
|
|
757
|
+
const opened = [];
|
|
758
|
+
const service = { url: 'http://127.0.0.1:1', headers: {} };
|
|
759
|
+
const integration = {
|
|
760
|
+
id: 'experiential-labs', name: 'Carthagent Ship',
|
|
761
|
+
methods: [{ id: 'carthagent-device', type: 'oauth', label: 'Sign in with Carthagent Ship' }],
|
|
762
|
+
};
|
|
763
|
+
const runtime = await createOpenCodeCliRuntime({ cwd: '/project', io: ioCapture().io, stdin: { isTTY: true }, stdout: { isTTY: true }, openBrowserImpl: async url => { opened.push(url); return true; }, native: {
|
|
764
|
+
runtime: {}, service: { ensureService: async () => service }, headless: {},
|
|
765
|
+
clientFactory: async () => ({ integration: {
|
|
766
|
+
list: async input => { calls.push(['list', input]); return { data: [integration] }; },
|
|
767
|
+
oauth: {
|
|
768
|
+
connect: async input => { calls.push(['connect', input]); return { data: {
|
|
769
|
+
attemptID: 'attempt-1', url: 'https://auth.example/device', instructions: 'Approve access', mode: 'auto',
|
|
770
|
+
time: { expires: Date.now() + 10_000 },
|
|
771
|
+
} }; },
|
|
772
|
+
status: async input => { calls.push(['status', input]); return { data: { status: 'complete', time: { created: 1, expires: 2 } } }; },
|
|
773
|
+
cancel: async input => { calls.push(['cancel', input]); },
|
|
774
|
+
},
|
|
775
|
+
} }),
|
|
776
|
+
} });
|
|
777
|
+
assert.equal(await runtime.login(), 0);
|
|
778
|
+
assert.deepEqual(opened, ['https://auth.example/device']);
|
|
779
|
+
assert.deepEqual(calls.map(([name]) => name), ['list', 'connect', 'status']);
|
|
780
|
+
assert.equal(calls[1][1].methodID, 'carthagent-device');
|
|
781
|
+
assert.deepEqual(calls[1][1].location, { directory: '/project' });
|
|
782
|
+
});
|
|
783
|
+
|
|
784
|
+
test('native login cancellation maps to 130 and attempts to cancel the pending host flow', async () => {
|
|
785
|
+
const calls = [];
|
|
786
|
+
const lines = [];
|
|
787
|
+
let statusReady;
|
|
788
|
+
const ready = new Promise(resolve => { statusReady = resolve; });
|
|
789
|
+
const runtime = await createOpenCodeCliRuntime({ cwd: '/project', io: { log: text => lines.push(text), error: text => lines.push(text) }, stdin: { isTTY: true }, stdout: { isTTY: true }, native: {
|
|
790
|
+
runtime: {}, service: { ensureService: async () => ({ url: 'http://127.0.0.1:1', headers: {} }) }, headless: {},
|
|
791
|
+
clientFactory: async () => ({ integration: {
|
|
792
|
+
list: async () => ({ data: [{ id: 'anthropic', name: 'Provider', methods: [{ id: 'device', type: 'oauth', label: 'Device' }] }] }),
|
|
793
|
+
oauth: {
|
|
794
|
+
connect: async () => ({ data: { attemptID: 'attempt-cancel', url: 'https://auth.example', instructions: 'Approve', mode: 'auto', time: { expires: Date.now() + 60_000 } } }),
|
|
795
|
+
status: async (_input, { signal }) => new Promise((_resolve, reject) => {
|
|
796
|
+
const onAbort = () => reject(signal.reason);
|
|
797
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
798
|
+
calls.push('status-listener-ready');
|
|
799
|
+
statusReady();
|
|
800
|
+
}),
|
|
801
|
+
cancel: async input => { calls.push(input.attemptID); },
|
|
802
|
+
},
|
|
803
|
+
} }),
|
|
804
|
+
} });
|
|
805
|
+
const running = runOpenCodeCli({ argv: ['login'], runtime, io: { log: text => lines.push(text), error: text => lines.push(text) }, stdoutIsTTY: true });
|
|
806
|
+
await ready;
|
|
807
|
+
process.emit('SIGINT');
|
|
808
|
+
assert.equal(await running, 130, lines.join('\n'));
|
|
809
|
+
assert.deepEqual(calls, ['status-listener-ready', 'attempt-cancel']);
|
|
810
|
+
});
|
|
811
|
+
|
|
812
|
+
test('native login honors an already-delivered SIGINT without starting an authentication RPC', async () => {
|
|
813
|
+
const calls = [];
|
|
814
|
+
const runtime = await createOpenCodeCliRuntime({ cwd: '/project', io: ioCapture().io, stdin: { isTTY: true }, stdout: { isTTY: true }, native: {
|
|
815
|
+
runtime: {}, service: { ensureService: async () => { process.emit('SIGINT'); return { url: 'http://127.0.0.1:1', headers: {} }; } }, headless: {},
|
|
816
|
+
clientFactory: async () => ({ integration: {
|
|
817
|
+
list: async () => ({ data: [{ id: 'anthropic', name: 'Provider', methods: [{ id: 'device', type: 'oauth', label: 'Device' }] }] }),
|
|
818
|
+
oauth: {
|
|
819
|
+
connect: async () => { calls.push('connect'); throw new Error('must not connect'); },
|
|
820
|
+
status: async () => { calls.push('status'); },
|
|
821
|
+
cancel: async () => { calls.push('cancel'); },
|
|
822
|
+
},
|
|
823
|
+
} }),
|
|
824
|
+
} });
|
|
825
|
+
assert.equal(await runOpenCodeCli({ argv: ['login'], runtime, io: ioCapture().io, stdoutIsTTY: true }), 130);
|
|
826
|
+
assert.deepEqual(calls, []);
|
|
827
|
+
});
|
|
828
|
+
|
|
829
|
+
test('native login cancels an OAuth attempt returned after SIGINT during connect', async () => {
|
|
830
|
+
const calls = [];
|
|
831
|
+
let connectReady;
|
|
832
|
+
let finishConnect;
|
|
833
|
+
const connectEntered = new Promise(resolve => { connectReady = resolve; });
|
|
834
|
+
const connectResponse = new Promise(resolve => { finishConnect = resolve; });
|
|
835
|
+
const runtime = await createOpenCodeCliRuntime({ cwd: '/project', io: ioCapture().io, stdin: { isTTY: true }, stdout: { isTTY: true }, native: {
|
|
836
|
+
runtime: {}, service: { ensureService: async () => ({ url: 'http://127.0.0.1:1', headers: {} }) }, headless: {},
|
|
837
|
+
clientFactory: async () => ({ integration: {
|
|
838
|
+
list: async () => ({ data: [{ id: 'anthropic', name: 'Provider', methods: [{ id: 'oauth', type: 'oauth', label: 'OAuth' }] }] }),
|
|
839
|
+
oauth: {
|
|
840
|
+
connect: async () => { calls.push('connect'); connectReady(); return connectResponse; },
|
|
841
|
+
status: async () => { calls.push('status'); return { data: { status: 'pending' } }; },
|
|
842
|
+
cancel: async input => { calls.push(['cancel', input.attemptID]); },
|
|
843
|
+
},
|
|
844
|
+
} }),
|
|
845
|
+
} });
|
|
846
|
+
const running = runOpenCodeCli({ argv: ['login'], runtime, io: ioCapture().io, stdoutIsTTY: true });
|
|
847
|
+
await connectEntered;
|
|
848
|
+
process.emit('SIGINT');
|
|
849
|
+
finishConnect({ data: { attemptID: 'attempt-connect-race', url: 'https://auth.example', instructions: 'Approve', mode: 'auto', time: { expires: Date.now() + 60_000 } } });
|
|
850
|
+
assert.equal(await running, 130);
|
|
851
|
+
assert.deepEqual(calls, ['connect', ['cancel', 'attempt-connect-race']]);
|
|
852
|
+
});
|
|
853
|
+
|
|
854
|
+
test('native login abort closes an in-flight ordinary OAuth form prompt', async () => {
|
|
855
|
+
const { EventEmitter } = await import('node:events');
|
|
856
|
+
const input = Object.assign(new EventEmitter(), { isTTY: true });
|
|
857
|
+
let promptClosed = false;
|
|
858
|
+
let promptReady;
|
|
859
|
+
const ready = new Promise(resolve => { promptReady = resolve; });
|
|
860
|
+
const runtime = await createOpenCodeCliRuntime({ cwd: '/project', io: ioCapture().io, stdin: input, stdout: { isTTY: true }, readlineFactory: () => ({
|
|
861
|
+
question: () => { promptReady(); return new Promise(() => {}); },
|
|
862
|
+
close: () => { promptClosed = true; },
|
|
863
|
+
}), native: {
|
|
864
|
+
runtime: {}, service: { ensureService: async () => ({ url: 'http://127.0.0.1:1', headers: {} }) }, headless: {},
|
|
865
|
+
clientFactory: async () => ({ integration: {
|
|
866
|
+
list: async () => ({ data: [{ id: 'anthropic', name: 'Provider', methods: [{ id: 'oauth', type: 'oauth', label: 'OAuth', form: [{ key: 'tenant', type: 'string', title: 'Tenant', required: true }] }] }] }),
|
|
867
|
+
oauth: {
|
|
868
|
+
connect: async () => { throw Error('connect must not run'); },
|
|
869
|
+
status: async () => ({ data: { status: 'pending' } }),
|
|
870
|
+
cancel: async () => {},
|
|
871
|
+
},
|
|
872
|
+
} }),
|
|
873
|
+
} });
|
|
874
|
+
const running = runOpenCodeCli({ argv: ['login'], runtime, io: ioCapture().io, stdoutIsTTY: true });
|
|
875
|
+
await ready;
|
|
876
|
+
process.emit('SIGINT');
|
|
877
|
+
assert.equal(await running, 130);
|
|
878
|
+
assert.equal(promptClosed, true);
|
|
879
|
+
});
|
|
880
|
+
|
|
881
|
+
test('native CLI resolves glyph mode for the host transport while preserving --ascii precedence', async () => {
|
|
882
|
+
const calls = [];
|
|
883
|
+
const runtime = await createOpenCodeCliRuntime({ cwd: '/project', io: ioCapture().io, native: {
|
|
884
|
+
runtime: {}, service: { ensureService: async () => { calls.push('ensure'); return {}; }, launchTui: (_service, _cwd, options) => { calls.push(options.glyphs); return { once(event, callback) { if (event === 'exit') queueMicrotask(() => callback(0)); return this; }, kill() {} }; } }, headless: {},
|
|
885
|
+
clientFactory: async () => ({ integration: {} }),
|
|
886
|
+
} });
|
|
887
|
+
const capture = ioCapture();
|
|
888
|
+
assert.equal(await runOpenCodeCli({ argv: ['--ascii'], runtime, io: capture.io, stdoutIsTTY: true }), 0);
|
|
889
|
+
assert.deepEqual(calls, ['ensure', 'ascii']);
|
|
890
|
+
});
|
|
891
|
+
|
|
892
|
+
test('native API-key login masks secrets; OAuth form input is masked and hidden fields retain defaults', async () => {
|
|
893
|
+
const calls = [];
|
|
894
|
+
const keyCalls = [];
|
|
895
|
+
const keyInput = Object.assign(new (await import('node:events')).EventEmitter(), { isTTY: true, isRaw: false, setRawMode(value) { this.isRaw = value; }, resume() {} });
|
|
896
|
+
const keyDisplayed = [];
|
|
897
|
+
const keyRuntime = await createOpenCodeCliRuntime({ cwd: '/project', io: ioCapture().io, stdin: keyInput, stdout: { isTTY: true, write: value => keyDisplayed.push(String(value)) }, native: {
|
|
898
|
+
runtime: {}, service: { ensureService: async () => ({ url: 'http://127.0.0.1:1', headers: {} }) }, headless: {},
|
|
899
|
+
clientFactory: async () => ({ integration: {
|
|
900
|
+
list: async () => ({ data: [{ id: 'anthropic', name: 'Key Provider', methods: [{ id: 'api-key', type: 'key', label: 'API key' }] }] }),
|
|
901
|
+
connect: { key: async input => keyCalls.push(input) },
|
|
902
|
+
} }),
|
|
903
|
+
} });
|
|
904
|
+
const keyLogin = keyRuntime.login({ args: ['anthropic', 'key'] });
|
|
905
|
+
await new Promise(resolve => setTimeout(resolve, 0));
|
|
906
|
+
keyInput.emit('data', 'fake-test-api-key\r');
|
|
907
|
+
assert.equal(await keyLogin, 0);
|
|
908
|
+
assert.equal(keyCalls[0].key, 'fake-test-api-key');
|
|
909
|
+
assert.ok(keyDisplayed.join('').includes('*'));
|
|
910
|
+
assert.ok(!keyDisplayed.join('').includes('fake-test-api-key'));
|
|
911
|
+
|
|
912
|
+
const displayed = [];
|
|
913
|
+
const fakeInput = Object.assign(new (await import('node:events')).EventEmitter(), { isTTY: true, isRaw: false, setRawMode(value) { this.isRaw = value; }, resume() {} });
|
|
914
|
+
const fakeOutput = { isTTY: true, write: value => displayed.push(String(value)) };
|
|
915
|
+
const oauthRuntime = await createOpenCodeCliRuntime({ cwd: '/project', io: ioCapture().io, stdin: fakeInput, stdout: fakeOutput, native: {
|
|
916
|
+
runtime: {}, service: { ensureService: async () => ({ url: 'http://127.0.0.1:1', headers: {} }) }, headless: {},
|
|
917
|
+
clientFactory: async () => ({ integration: {
|
|
918
|
+
list: async () => ({ data: [{ id: 'anthropic', name: 'OAuth Provider', methods: [{
|
|
919
|
+
id: 'oauth', type: 'oauth', label: 'OAuth', form: [
|
|
920
|
+
{ key: 'client_secret', type: 'string', title: 'Client secret', required: true },
|
|
921
|
+
{ key: 'scope', type: 'string', title: 'Scope', default: 'default-scope', hidden: true },
|
|
922
|
+
],
|
|
923
|
+
}] }] }),
|
|
924
|
+
oauth: {
|
|
925
|
+
connect: async input => {
|
|
926
|
+
calls.push(input.answer);
|
|
927
|
+
return { data: { attemptID: 'a', mode: 'auto', url: 'https://example.test', instructions: 'auth', time: { expires: Date.now() + 1000 } } };
|
|
928
|
+
},
|
|
929
|
+
status: async () => ({ data: { status: 'complete' } }),
|
|
930
|
+
},
|
|
931
|
+
} }),
|
|
932
|
+
} });
|
|
933
|
+
const login = oauthRuntime.login({ args: ['anthropic'] });
|
|
934
|
+
await new Promise(resolve => setTimeout(resolve, 0));
|
|
935
|
+
fakeInput.emit('data', 'sensitive-value\r');
|
|
936
|
+
await login;
|
|
937
|
+
assert.deepEqual(calls[0], { client_secret: 'sensitive-value', scope: 'default-scope' });
|
|
938
|
+
assert.ok(displayed.join('').includes('**************'));
|
|
939
|
+
assert.ok(!displayed.join('').includes('sensitive-value'));
|
|
940
|
+
});
|
|
941
|
+
|
|
942
|
+
test('invalid native review arguments fail with usage before touching workspace files or OpenCode', async () => {
|
|
943
|
+
let touched = false;
|
|
944
|
+
const runtime = await createOpenCodeCliRuntime({ cwd: '/definitely/not/a/workspace', io: ioCapture().io, native: {
|
|
945
|
+
runtime: {}, service: { ensureService: async () => { touched = true; throw new Error('service must not start'); } }, headless: {},
|
|
946
|
+
runCommand: async () => { touched = true; throw new Error('commands must not run'); },
|
|
947
|
+
} });
|
|
948
|
+
for (const args of [['bogus'], ['0'], ['-1'], ['123extra'], ['https://example.com/pull/3'], ['workspace', 'extra'], ['--timeout', '0', '12']]) {
|
|
949
|
+
const capture = ioCapture();
|
|
950
|
+
const code = await runtime.review({ args }).catch(error => { capture.io.error(error.message); return error.exitCode ?? 1; });
|
|
951
|
+
assert.equal(code, 2, `args=${args.join(' ')}`);
|
|
952
|
+
assert.match(capture.output.stderr.join('\n'), /review|Review|Invalid|Use/);
|
|
953
|
+
}
|
|
954
|
+
assert.equal(touched, false);
|
|
955
|
+
});
|
|
956
|
+
|
|
957
|
+
test('native workspace review preserves raw metadata and returns usage exit codes for invalid structured outcomes', async t => {
|
|
958
|
+
const { mkdtempSync, rmSync } = await import('node:fs');
|
|
959
|
+
const { tmpdir } = await import('node:os');
|
|
960
|
+
const { join } = await import('node:path');
|
|
961
|
+
const { execFileSync } = await import('node:child_process');
|
|
962
|
+
const cwd = mkdtempSync(join(tmpdir(), 'ctg-cli-review-contract-'));
|
|
963
|
+
t.after(() => rmSync(cwd, { recursive: true, force: true }));
|
|
964
|
+
execFileSync('git', ['init', '-q'], { cwd });
|
|
965
|
+
execFileSync('git', ['config', 'user.email', 'fixture@example.invalid'], { cwd });
|
|
966
|
+
execFileSync('git', ['config', 'user.name', 'Fixture'], { cwd });
|
|
967
|
+
const { writeFileSync } = await import('node:fs');
|
|
968
|
+
writeFileSync(join(cwd, 'source.txt'), 'base\n');
|
|
969
|
+
writeFileSync(join(cwd, 'task.txt'), 'Make the requested change safely.\n');
|
|
970
|
+
execFileSync('git', ['add', '.'], { cwd });
|
|
971
|
+
execFileSync('git', ['commit', '-qm', 'fixture'], { cwd });
|
|
972
|
+
writeFileSync(join(cwd, 'source.txt'), 'changed\n');
|
|
973
|
+
let mode = 'missing';
|
|
974
|
+
const runtime = await createOpenCodeCliRuntime({ cwd, native: {
|
|
975
|
+
runtime: {}, service: { ensureService: async () => ({ url: 'http://127.0.0.1:1', headers: {} }) }, headless: {},
|
|
976
|
+
clientFactory: async () => ({
|
|
977
|
+
location: { get: async () => ({}) },
|
|
978
|
+
model: { list: async () => ({ data: [] }) },
|
|
979
|
+
agent: { list: async () => ({ data: [{ id: 'build' }] }) },
|
|
980
|
+
session: {
|
|
981
|
+
create: async input => { globalThis.fixtureReviewer = { permissions: input.permissions }; return { id: 'reviewer' }; },
|
|
982
|
+
prompt: async input => { globalThis.fixtureReviewer = { ...globalThis.fixtureReviewer, prompt: input }; },
|
|
983
|
+
wait: async () => {},
|
|
984
|
+
get: async () => ({ outcome: 'succeeded' }),
|
|
985
|
+
context: async () => {
|
|
986
|
+
const prompt = globalThis.fixtureReviewer.prompt.text;
|
|
987
|
+
const head = prompt.match(/HEAD: ([a-f0-9]+)/)?.[1];
|
|
988
|
+
const snapshot = prompt.match(/Snapshot SHA-256: ([a-f0-9]+)/)?.[1];
|
|
989
|
+
if (mode === 'missing') return [{ type: 'assistant', finish: 'stop', content: [{ type: 'text', text: 'VERDICT: APPROVE' }] }, { type: 'idle', outcome: 'succeeded' }];
|
|
990
|
+
return [{ type: 'assistant', finish: 'stop', content: [{ type: 'text', text: `REVIEW_JSON: ${JSON.stringify({ version: 1, snapshot, head, findings: [] })}\nVERDICT: APPROVE` }] }, { type: 'idle', outcome: 'succeeded' }];
|
|
991
|
+
},
|
|
992
|
+
},
|
|
993
|
+
}),
|
|
994
|
+
} });
|
|
995
|
+
await assert.rejects(runtime.review({ args: ['bogus'] }), { exitCode: 2 });
|
|
996
|
+
const reviewArgs = ['workspace', '--request-file', 'task.txt'];
|
|
997
|
+
const error = await runtime.review({ args: reviewArgs }).then(() => null, value => value);
|
|
998
|
+
assert.equal(error.exitCode, 2);
|
|
999
|
+
mode = 'valid';
|
|
1000
|
+
assert.deepEqual(globalThis.fixtureReviewer.prompt.metadata, { 'ctg.raw': true });
|
|
1001
|
+
const result = await runtime.review({ args: reviewArgs });
|
|
1002
|
+
assert.equal(result, 0);
|
|
1003
|
+
delete globalThis.fixtureReviewer;
|
|
1004
|
+
});
|
|
1005
|
+
|
|
1006
|
+
test('native Ship billing and logout explain how to connect when the typed auth error is returned', async () => {
|
|
1007
|
+
const runtime = await createOpenCodeCliRuntime({ cwd: '/project', io: ioCapture().io, native: {
|
|
1008
|
+
runtime: {}, service: { ensureService: async () => ({ url: 'http://127.0.0.1:1', headers: {} }) }, headless: {},
|
|
1009
|
+
clientFactory: async () => ({ rpc: () => ({
|
|
1010
|
+
shipBilling: async () => { throw Object.assign(new Error('Not authenticated'), { type: 'ship.unauthenticated' }); },
|
|
1011
|
+
shipLogout: async () => { throw Object.assign(new Error('Not authenticated'), { type: 'ship.unauthenticated' }); },
|
|
1012
|
+
}) }),
|
|
1013
|
+
} });
|
|
1014
|
+
await assert.rejects(runtime.billing({ action: 'checkout' }), /not connected.*ctg login.*Carthagent Ship/i);
|
|
1015
|
+
await assert.rejects(runtime.logout({}), /not connected.*ctg login.*Carthagent Ship/i);
|
|
1016
|
+
});
|
|
1017
|
+
|
|
1018
|
+
test('OAuth forms honor conditional fields, defaults, validation, and typed selections', async () => {
|
|
1019
|
+
const calls = [];
|
|
1020
|
+
const input = Object.assign(new (await import('node:events')).EventEmitter(), {
|
|
1021
|
+
isTTY: true, isRaw: false,
|
|
1022
|
+
setRawMode(value) { this.isRaw = value; },
|
|
1023
|
+
resume() {},
|
|
1024
|
+
});
|
|
1025
|
+
const replies = ['standard\r', '4\r', 'true\r', 'alpha,beta\r'];
|
|
1026
|
+
let promptIndex = 0;
|
|
1027
|
+
const fakeStdout = { isTTY: true, write() {}, on() {}, removeListener() {}, end() {} };
|
|
1028
|
+
const readlineFactory = options => {
|
|
1029
|
+
return { close() {}, question: async label => {
|
|
1030
|
+
assert.ok(label.includes(['Mode', 'Count', 'Enabled', 'Features'][promptIndex]));
|
|
1031
|
+
return replies[promptIndex++].trimEnd();
|
|
1032
|
+
} };
|
|
1033
|
+
};
|
|
1034
|
+
const injectedRuntime = await createOpenCodeCliRuntime({ cwd: '/project', io: ioCapture().io, stdin: input, stdout: fakeStdout, readlineFactory, native: {
|
|
1035
|
+
runtime: {}, service: { ensureService: async () => ({ url: 'http://127.0.0.1:1', headers: {} }) }, headless: {},
|
|
1036
|
+
clientFactory: async () => ({ integration: {
|
|
1037
|
+
list: async () => ({ data: [{ id: 'anthropic', name: 'Forms', methods: [{ id: 'oauth', type: 'oauth', label: 'OAuth', form: [
|
|
1038
|
+
{ key: 'mode', type: 'string', title: 'Mode', required: true, options: [{ value: 'standard', label: 'Standard' }, { value: 'advanced', label: 'Advanced' }] },
|
|
1039
|
+
{ key: 'count', type: 'integer', title: 'Count', required: true, minimum: 1, maximum: 5, default: 4, when: [{ key: 'mode', op: 'eq', value: 'standard' }] },
|
|
1040
|
+
{ key: 'enabled', type: 'boolean', title: 'Enabled', default: true },
|
|
1041
|
+
{ key: 'features', type: 'multiselect', title: 'Features', options: [{ value: 'alpha', label: 'Alpha' }, { value: 'beta', label: 'Beta' }], minItems: 1, maxItems: 2 },
|
|
1042
|
+
] }] }] }),
|
|
1043
|
+
oauth: {
|
|
1044
|
+
connect: async value => { calls.push(value.answer); return { data: { attemptID: 'form', mode: 'auto', url: 'https://auth.example', instructions: 'approve', time: { expires: Date.now() + 1000 } } }; },
|
|
1045
|
+
status: async () => ({ data: { status: 'complete' } }),
|
|
1046
|
+
},
|
|
1047
|
+
} }),
|
|
1048
|
+
} });
|
|
1049
|
+
await injectedRuntime.login({ args: ['anthropic'] });
|
|
1050
|
+
assert.deepEqual(calls[0], { mode: 'standard', count: 4, enabled: true, features: ['alpha', 'beta'] });
|
|
1051
|
+
assert.equal(promptIndex, 4);
|
|
1052
|
+
});
|
|
1053
|
+
|
|
1054
|
+
test('OAuth optional boolean fields keep their native default when left blank', async () => {
|
|
1055
|
+
const calls = [];
|
|
1056
|
+
const output = ioCapture();
|
|
1057
|
+
const runtime = await createOpenCodeCliRuntime({ cwd: '/project', io: output.io, stdin: { isTTY: true }, stdout: { isTTY: true, write() {} }, readlineFactory: () => ({ close() {}, question: async () => '' }), native: {
|
|
1058
|
+
runtime: {}, service: { ensureService: async () => ({ url: 'http://127.0.0.1:1', headers: {} }) }, headless: {},
|
|
1059
|
+
clientFactory: async () => ({ integration: {
|
|
1060
|
+
list: async () => ({ data: [{ id: 'anthropic', name: 'Boolean', methods: [{ id: 'oauth', type: 'oauth', label: 'OAuth', form: [{ key: 'enabled', type: 'boolean', default: true }] }] }] }),
|
|
1061
|
+
oauth: {
|
|
1062
|
+
connect: async input => { calls.push(input.answer); return { data: { attemptID: 'anthropic', mode: 'auto', url: 'https://auth.example', instructions: 'approve', time: { expires: Date.now() + 1000 } } }; },
|
|
1063
|
+
status: async () => ({ data: { status: 'complete' } }),
|
|
1064
|
+
},
|
|
1065
|
+
} }),
|
|
1066
|
+
} });
|
|
1067
|
+
await runtime.login({ args: ['anthropic'] });
|
|
1068
|
+
assert.deepEqual(calls, [{ enabled: true }]);
|
|
1069
|
+
});
|
|
1070
|
+
|
|
1071
|
+
test('native account and logout use the active profile RPC and remove only its credential after revoke', async () => {
|
|
1072
|
+
const calls = [];
|
|
1073
|
+
const service = { url: 'http://127.0.0.1:1', headers: {} };
|
|
1074
|
+
const rpcDefinition = {};
|
|
1075
|
+
const account = { accountId: 'acct-profile', balance: { availableNanoUsd: 100 }, subscription: { status: 'none' }, grants: [], sessions: [] };
|
|
1076
|
+
const runtime = await createOpenCodeCliRuntime({ cwd: '/project', io: ioCapture().io, native: {
|
|
1077
|
+
runtime: {}, service: { ensureService: async () => service }, headless: {},
|
|
1078
|
+
rpcDefinitions: { Carthagent: rpcDefinition },
|
|
1079
|
+
clientFactory: async () => ({
|
|
1080
|
+
rpc: definition => {
|
|
1081
|
+
assert.equal(definition, rpcDefinition);
|
|
1082
|
+
return {
|
|
1083
|
+
shipAccount: async input => { calls.push(['account', input]); return { account }; },
|
|
1084
|
+
shipBilling: async input => { calls.push(['billing', input]); return { url: 'https://billing.test/portal' }; },
|
|
1085
|
+
shipLogout: async input => { calls.push(['logout', input]); return { revoked: true, credentialID: 'profile-credential-id' }; },
|
|
1086
|
+
};
|
|
1087
|
+
},
|
|
1088
|
+
credential: { remove: async input => { calls.push(['remove', input]); } },
|
|
1089
|
+
}),
|
|
1090
|
+
} });
|
|
1091
|
+
assert.equal(await runtime.account({ args: ['--json'] }), 0);
|
|
1092
|
+
assert.equal(await runtime.logout({}), 0);
|
|
1093
|
+
assert.deepEqual(calls, [
|
|
1094
|
+
['account', {}], ['logout', {}], ['remove', { credentialID: 'profile-credential-id' }],
|
|
1095
|
+
]);
|
|
1096
|
+
});
|
|
1097
|
+
|
|
1098
|
+
test('native named-session logout revokes only that session and leaves the current profile credential intact', async () => {
|
|
1099
|
+
const calls = [];
|
|
1100
|
+
const runtime = await createOpenCodeCliRuntime({ cwd: '/project', native: {
|
|
1101
|
+
runtime: {}, service: { ensureService: async () => ({ url: 'http://127.0.0.1:1', headers: {} }) }, headless: {},
|
|
1102
|
+
rpcDefinitions: { Carthagent: {} },
|
|
1103
|
+
clientFactory: async () => ({ rpc: () => ({ shipLogout: async input => { calls.push(['revoke', input]); return { revoked: true }; } }), credential: { remove: async input => calls.push(['remove', input]) } }),
|
|
1104
|
+
} });
|
|
1105
|
+
assert.equal(await runtime.logout({ session: 'ship-session-2' }), 0);
|
|
1106
|
+
assert.deepEqual(calls, [['revoke', { session: 'ship-session-2' }]]);
|
|
1107
|
+
});
|
|
1108
|
+
|
|
1109
|
+
test('native demo runs a clearly labeled offline fixture without starting OpenCode or touching the project', async t => {
|
|
1110
|
+
const { mkdtempSync, readFileSync, rmSync } = await import('node:fs');
|
|
1111
|
+
const { tmpdir } = await import('node:os');
|
|
1112
|
+
const { join } = await import('node:path');
|
|
1113
|
+
const cwd = mkdtempSync(join(tmpdir(), 'ctg-demo-project-'));
|
|
1114
|
+
t.after(() => rmSync(cwd, { recursive: true, force: true }));
|
|
1115
|
+
const calls = [];
|
|
1116
|
+
const output = ioCapture();
|
|
1117
|
+
const runtime = await createOpenCodeCliRuntime({ cwd, io: output.io, native: {
|
|
1118
|
+
runtime: {}, service: { ensureService: async () => { throw new Error('offline demo must not start OpenCode'); } }, headless: {},
|
|
1119
|
+
demoExecute: async (argv, options) => {
|
|
1120
|
+
calls.push({ argv, options, app: readFileSync(join(options.cwd, 'app.mjs'), 'utf8') });
|
|
1121
|
+
return { code: 0, output: '2 tests passed', timedOut: false, cancelled: false };
|
|
1122
|
+
},
|
|
1123
|
+
} });
|
|
1124
|
+
assert.equal(await runtime.command({ command: 'demo', args: [] }), 0);
|
|
1125
|
+
assert.equal(calls.length, 1);
|
|
1126
|
+
assert.equal(calls[0].argv.at(-1), 'test/app.test.mjs');
|
|
1127
|
+
assert.notEqual(calls[0].options.cwd, cwd);
|
|
1128
|
+
assert.match(calls[0].app, /export function summarize/);
|
|
1129
|
+
assert.match(output.output.stdout.join('\n'), /offline demo/i);
|
|
1130
|
+
assert.match(output.output.stdout.join('\n'), /no provider\/API calls/);
|
|
1131
|
+
assert.match(output.output.stdout.join('\n'), /disposable demo fixture/);
|
|
1132
|
+
});
|
|
1133
|
+
|
|
1134
|
+
test('native update prefers the exact published registry package', async () => {
|
|
1135
|
+
const calls = [];
|
|
1136
|
+
const runtime = await createOpenCodeCliRuntime({ cwd: '/demo/workspace', native: {
|
|
1137
|
+
runtime: {}, service: { ensureService: async () => { throw new Error('demo must not launch the native service'); } }, headless: {},
|
|
1138
|
+
updateApi: {
|
|
1139
|
+
fetchLatestRelease: async () => ({ release: { version: '1.0.4', tag: 'v1.0.4', minNode: '18.0.0', installSpec: 'github:B-icy/Carthagent#v1.0.4' } }),
|
|
1140
|
+
packageVersion: () => '1.0.3',
|
|
1141
|
+
detectInstallMethod: () => 'npm',
|
|
1142
|
+
},
|
|
1143
|
+
packageRoot: '/package/root',
|
|
1144
|
+
updateModule: { runNativeUpdate },
|
|
1145
|
+
runCommand: async (argv, options) => { calls.push({ argv, options }); return { code: 0, timedOut: false, cancelled: false, output: '' }; },
|
|
1146
|
+
} });
|
|
1147
|
+
assert.equal(await runtime.command({ command: 'update', args: [] }), 0);
|
|
1148
|
+
const npmPrefix = process.platform === 'win32' ? ['cmd.exe', '/d', '/s', '/c', 'npm'] : ['npm'];
|
|
1149
|
+
assert.deepEqual(calls, [{
|
|
1150
|
+
argv: [...npmPrefix, 'view', 'carthagent@1.0.4', 'version', '--json'],
|
|
1151
|
+
options: { cwd: '/package/root', timeoutSeconds: 10 },
|
|
1152
|
+
}, {
|
|
1153
|
+
argv: [...npmPrefix, 'install', '-g', '--install-links=true', 'carthagent@1.0.4'],
|
|
1154
|
+
options: { cwd: '/package/root', timeoutSeconds: 600 },
|
|
1155
|
+
}]);
|
|
1156
|
+
});
|
|
1157
|
+
test('native update check reports an available release without invoking the installer', async () => {
|
|
1158
|
+
const calls = [];
|
|
1159
|
+
const output = ioCapture();
|
|
1160
|
+
const runtime = await createOpenCodeCliRuntime({ cwd: '/package/root', io: output.io, native: {
|
|
1161
|
+
runtime: {}, service: {}, headless: {},
|
|
1162
|
+
updateApi: {
|
|
1163
|
+
fetchLatestRelease: async () => ({ release: { version: '1.0.4', tag: 'v1.0.4', minNode: '18.0.0', installSpec: 'github:B-icy/Carthagent#v1.0.4' } }),
|
|
1164
|
+
packageVersion: () => '1.0.3', detectInstallMethod: () => 'npm',
|
|
1165
|
+
},
|
|
1166
|
+
updateModule: { runNativeUpdate },
|
|
1167
|
+
runCommand: async (...args) => { calls.push(args); return { code: 0 }; },
|
|
1168
|
+
} });
|
|
1169
|
+
assert.equal(await runtime.command({ command: 'update', args: ['--check'] }), 0);
|
|
1170
|
+
assert.match(output.output.stdout[0], /1\.0\.4 is available/);
|
|
1171
|
+
assert.deepEqual(calls, []);
|
|
1172
|
+
});
|
|
1173
|
+
|
|
1174
|
+
test('native updater force-reinstalls only the canonical validated release tag once', async () => {
|
|
1175
|
+
const installs = [];
|
|
1176
|
+
const runtime = await createOpenCodeCliRuntime({ cwd: '/package/root', native: {
|
|
1177
|
+
runtime: {}, service: {}, headless: {}, packageRoot: '/package/root',
|
|
1178
|
+
updateApi: {
|
|
1179
|
+
fetchLatestRelease: async () => ({ release: { version: '1.0.4', tag: 'v1.0.4', installSpec: 'github:B-icy/Carthagent#v1.0.4' } }),
|
|
1180
|
+
packageVersion: () => '1.0.4',
|
|
1181
|
+
detectInstallMethod: () => 'npm',
|
|
1182
|
+
},
|
|
1183
|
+
updateModule: { runNativeUpdate },
|
|
1184
|
+
runCommand: async (argv, options) => { installs.push({ argv, options }); return { code: 0, timedOut: false, cancelled: false, output: '' }; },
|
|
1185
|
+
} });
|
|
1186
|
+
assert.equal(await runtime.command({ command: 'update', args: ['--force'] }), 0);
|
|
1187
|
+
const npmPrefix = process.platform === 'win32' ? ['cmd.exe', '/d', '/s', '/c', 'npm'] : ['npm'];
|
|
1188
|
+
assert.deepEqual(installs, [{
|
|
1189
|
+
argv: [...npmPrefix, 'view', 'carthagent@1.0.4', 'version', '--json'],
|
|
1190
|
+
options: { cwd: '/package/root', timeoutSeconds: 10 },
|
|
1191
|
+
}, {
|
|
1192
|
+
argv: [...npmPrefix, 'install', '-g', '--install-links=true', 'carthagent@1.0.4'],
|
|
1193
|
+
options: { cwd: '/package/root', timeoutSeconds: 600 },
|
|
1194
|
+
}]);
|
|
1195
|
+
});
|
|
1196
|
+
|
|
1197
|
+
test('native serve child does not inherit runtime download tokens', async t => {
|
|
1198
|
+
const { mkdtempSync, readFileSync, rmSync, writeFileSync } = await import('node:fs');
|
|
1199
|
+
const { tmpdir } = await import('node:os');
|
|
1200
|
+
const { join } = await import('node:path');
|
|
1201
|
+
const runtimeRoot = mkdtempSync(join(tmpdir(), 'ctg-serve-env-'));
|
|
1202
|
+
t.after(() => rmSync(runtimeRoot, { recursive: true, force: true }));
|
|
1203
|
+
const resultFile = join(runtimeRoot, 'result.json');
|
|
1204
|
+
writeFileSync(join(runtimeRoot, 'server.mjs'), `import { writeFileSync } from 'node:fs'; writeFileSync(${JSON.stringify(resultFile)}, JSON.stringify({ workspace: process.env.CARTHAGENT_WORKSPACE, other: process.env.CTG_RUNTIME_OTHER, tokens: Object.keys(process.env).filter(key => key.toUpperCase() === 'CTG_RUNTIME_GITHUB_TOKEN') }));`);
|
|
1205
|
+
const previous = Object.fromEntries(['CTG_RUNTIME_GITHUB_TOKEN', 'ctg_runtime_github_token', 'Ctg_Runtime_Github_Token', 'CTG_RUNTIME_OTHER'].map(key => [key, process.env[key]]));
|
|
1206
|
+
Object.assign(process.env, {
|
|
1207
|
+
CTG_RUNTIME_GITHUB_TOKEN: 'fake-uppercase-token',
|
|
1208
|
+
ctg_runtime_github_token: 'fake-lowercase-token',
|
|
1209
|
+
Ctg_Runtime_Github_Token: 'fake-mixedcase-token',
|
|
1210
|
+
CTG_RUNTIME_OTHER: 'preserve-this-value',
|
|
1211
|
+
});
|
|
1212
|
+
try {
|
|
1213
|
+
const runtime = await createOpenCodeCliRuntime({ cwd: '/workspace/fixture', native: { runtime: {}, service: {}, headless: {}, runtimeRoot } });
|
|
1214
|
+
assert.equal(await runtime.command({ command: 'serve', args: [] }), 0);
|
|
1215
|
+
assert.deepEqual(JSON.parse(readFileSync(resultFile, 'utf8')), {
|
|
1216
|
+
workspace: '/workspace/fixture', other: 'preserve-this-value', tokens: [],
|
|
1217
|
+
});
|
|
1218
|
+
} finally {
|
|
1219
|
+
for (const [key, value] of Object.entries(previous)) {
|
|
1220
|
+
if (value === undefined) delete process.env[key];
|
|
1221
|
+
else process.env[key] = value;
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
});
|
|
1225
|
+
|
|
1226
|
+
test('concrete headless runtime returns failure for failed native session outcome', async t => {
|
|
1227
|
+
const { mkdtempSync, rmSync } = await import('node:fs');
|
|
1228
|
+
const { tmpdir } = await import('node:os');
|
|
1229
|
+
const { join } = await import('node:path');
|
|
1230
|
+
const cwd = mkdtempSync(join(tmpdir(), 'ctg-opencode-failed-'));
|
|
1231
|
+
t.after(() => rmSync(cwd, { recursive: true, force: true }));
|
|
1232
|
+
const service = { url: 'http://127.0.0.1:1', headers: {}, binary: '/fixture/opencode', paths: { root: '/fixture/home' } };
|
|
1233
|
+
const runtime = await createOpenCodeCliRuntime({ cwd, io: ioCapture().io, stdout: { write() {} }, native: {
|
|
1234
|
+
runtime: {},
|
|
1235
|
+
service: { ensureService: async () => service },
|
|
1236
|
+
headless: { runHeadless: async () => ({
|
|
1237
|
+
session: { id: 'ses_failed' },
|
|
1238
|
+
messages: [
|
|
1239
|
+
{ type: 'assistant', finish: 'error', error: { message: 'provider failure' }, parts: [{ type: 'text', text: 'provider failure' }] },
|
|
1240
|
+
{ type: 'idle', outcome: 'failed' },
|
|
1241
|
+
],
|
|
1242
|
+
}) },
|
|
1243
|
+
clientFactory: async () => ({ session: {
|
|
1244
|
+
get: async () => ({ id: 'ses_failed', outcome: 'failed' }),
|
|
1245
|
+
context: async () => [
|
|
1246
|
+
{ type: 'assistant', finish: 'error', error: { message: 'provider failure' } },
|
|
1247
|
+
{ type: 'idle', outcome: 'failed' },
|
|
1248
|
+
],
|
|
1249
|
+
} }),
|
|
1250
|
+
} });
|
|
1251
|
+
assert.equal(await runtime.run({ mode: 'headless', options: { prompt: 'Run' }, prompt: 'Run' }), 1);
|
|
1252
|
+
});
|
|
1253
|
+
|
|
1254
|
+
test('concrete native runtime uses OpenCode service/headless/model APIs and real delivery status', async t => {
|
|
1255
|
+
const { mkdtempSync, writeFileSync, rmSync } = await import('node:fs');
|
|
1256
|
+
const { tmpdir } = await import('node:os');
|
|
1257
|
+
const { join, resolve } = await import('node:path');
|
|
1258
|
+
const cwd = mkdtempSync(join(tmpdir(), 'ctg-opencode-cli-'));
|
|
1259
|
+
t.after(() => rmSync(cwd, { recursive: true, force: true }));
|
|
1260
|
+
writeFileSync(join(cwd, 'source.mjs'), 'export default true;\n');
|
|
1261
|
+
const events = [];
|
|
1262
|
+
const service = { url: 'http://127.0.0.1:1', headers: {}, binary: '/fixture/opencode', paths: { root: '/fixture/home' } };
|
|
1263
|
+
const native = {
|
|
1264
|
+
runtime: {
|
|
1265
|
+
runtimeEnvironment: () => ({ CTG_FIXTURE: '1' }),
|
|
1266
|
+
readRuntimeCredentials: async () => ({ 'experiential-labs': { access: 'fixture-access' } }),
|
|
1267
|
+
},
|
|
1268
|
+
service: {
|
|
1269
|
+
ensureService: async options => { events.push(['service', options.pluginPath]); return service; },
|
|
1270
|
+
launchTui: (selectedService, workspace, options) => {
|
|
1271
|
+
events.push(['tui', selectedService === service, workspace, options.sessionID]);
|
|
1272
|
+
return { once(event, callback) { if (event === 'exit') queueMicrotask(() => callback(0, null)); } };
|
|
1273
|
+
},
|
|
1274
|
+
},
|
|
1275
|
+
headless: {
|
|
1276
|
+
runHeadless: async (selectedService, workspace, prompt, options) => {
|
|
1277
|
+
events.push(['headless', selectedService === service, workspace, prompt, options.sessionID]);
|
|
1278
|
+
return { session: { id: 'ses_fixture' }, outcome: 'succeeded', terminal: true, accepted: { id: 'accepted-fixture' }, messages: [
|
|
1279
|
+
{ type: 'user', id: 'accepted-fixture' },
|
|
1280
|
+
{ type: 'assistant', finish: 'stop', parts: [{ type: 'text', text: 'native answer' }] },
|
|
1281
|
+
{ type: 'idle', outcome: 'succeeded' },
|
|
1282
|
+
] };
|
|
1283
|
+
},
|
|
1284
|
+
},
|
|
1285
|
+
clientFactory: async () => ({
|
|
1286
|
+
session: {
|
|
1287
|
+
get: async () => ({ id: 'ses_fixture', outcome: 'succeeded' }),
|
|
1288
|
+
context: async () => [
|
|
1289
|
+
{ type: 'user', id: 'accepted-fixture' },
|
|
1290
|
+
{ type: 'assistant', finish: 'stop', parts: [{ type: 'text', text: 'native answer' }] },
|
|
1291
|
+
{ type: 'idle', outcome: 'succeeded' },
|
|
1292
|
+
],
|
|
1293
|
+
},
|
|
1294
|
+
model: { list: async () => ({ data: [
|
|
1295
|
+
{ providerID: 'anthropic', modelID: 'claude-fixture', name: 'Claude Fixture', limit: { context: 100, output: 10 }, cost: [{ input: 1, output: 2 }] },
|
|
1296
|
+
] }) },
|
|
1297
|
+
}),
|
|
1298
|
+
};
|
|
1299
|
+
const output = [];
|
|
1300
|
+
const io = { log: text => output.push(String(text)), error: text => output.push(`ERR:${text}`) };
|
|
1301
|
+
const stdout = { write: text => output.push(String(text)) };
|
|
1302
|
+
const runtime = await createOpenCodeCliRuntime({ cwd, native, io, stdout });
|
|
1303
|
+
assert.equal(await runtime.run({ mode: 'headless', options: { prompt: 'Do a task' }, prompt: 'Do a task' }), 0);
|
|
1304
|
+
assert.equal(output.at(-1), 'native answer\n');
|
|
1305
|
+
assert.equal(await runtime.listModels({ query: 'claude' }), 1);
|
|
1306
|
+
assert.match(output.at(-1), /Claude \/ Anthropic\s+claude-fixture\s+100\s+10/);
|
|
1307
|
+
assert.equal(await runtime.run({ mode: 'interactive', options: {}, prompt: undefined }), 0);
|
|
1308
|
+
assert.ok(events.some(entry => entry[0] === 'service'));
|
|
1309
|
+
assert.ok(events.some(entry => entry[0] === 'headless' && entry[3] === 'Do a task'));
|
|
1310
|
+
assert.ok(events.some(entry => entry[0] === 'tui' && entry[2] === cwd));
|
|
1311
|
+
assert.equal(resolve(cwd), cwd);
|
|
1312
|
+
});
|