carthagent 1.0.7 → 1.1.0-beta.2
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/beta-rewards-e2e-plan.md +48 -0
- package/docs/beta-rewards-e2e-results.md +84 -0
- package/docs/cloud-onboarding-acceptance.md +65 -0
- 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/launch-readiness-live-results.md +65 -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 +175 -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-beta.1.md +60 -0
- package/docs/release-notes-beta.2.md +60 -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/cloud/client.mjs +13 -7
- package/lib/codegraph.mjs +1 -1
- package/lib/delivery-review.mjs +19 -6
- package/lib/delivery.mjs +3 -2
- package/lib/environment.mjs +8 -0
- package/lib/guidance.mjs +1 -1
- package/lib/opencode/cli-runtime.mjs +1188 -0
- package/lib/opencode/cli.mjs +253 -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 +22 -5
- 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/providers/names.mjs +1 -1
- package/lib/tui/framing.mjs +1 -80
- package/lib/tui/models.mjs +1 -1
- 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 +243 -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 +65 -36
- 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 +71 -0
- package/plugins/carthagent/src/server/voyages-rpc.ts +280 -0
- package/plugins/carthagent/src/testing.ts +5 -0
- package/plugins/carthagent/src/tui.tsx +506 -0
- package/plugins/carthagent/src/ui/client.ts +127 -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 +165 -0
- package/plugins/carthagent/src/ui/voyages.tsx +131 -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 +64 -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/cloud-client.test.mjs +2 -2
- package/tests/daily-evidence.test.mjs +62 -0
- package/tests/delivery-review.test.mjs +57 -2
- package/tests/delivery.test.mjs +22 -1
- package/tests/experiential-extension.test.mjs +2 -2
- package/tests/experiential.test.mjs +1 -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 +55 -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 +525 -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 +33 -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-guidance.test.mjs +16 -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 +83 -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 +397 -0
- package/tests/native-voyages-ui.test.mjs +307 -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 +248 -20
- package/tests/opencode-runtime.test.mjs +597 -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 +231 -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/tui.test.mjs +2 -2
- 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 +45 -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 +882 -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 +91 -65
- 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,33 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { NATIVE_LOGIN_PROVIDERS, nativeLoginProvider, nativeLoginProviders, nativeProviderName } from '../lib/opencode/provider-policy.mjs';
|
|
4
|
+
import { cliHelp } from '../lib/opencode/cli.mjs';
|
|
5
|
+
|
|
6
|
+
test('native login policy stays small, named, ordered and excludes upstream providers', () => {
|
|
7
|
+
assert.deepEqual(NATIVE_LOGIN_PROVIDERS.map(provider => provider.id), ['experiential-labs', 'anthropic', 'xai', 'openai', 'google']);
|
|
8
|
+
assert.equal(nativeLoginProvider('opencode'), undefined);
|
|
9
|
+
assert.equal(nativeLoginProvider('opencode-go'), undefined);
|
|
10
|
+
assert.equal(nativeLoginProvider('openrouter'), undefined);
|
|
11
|
+
assert.equal(nativeProviderName('ship'), 'Carthagent Cloud');
|
|
12
|
+
assert.equal(nativeProviderName('Carthagent Ship'), 'Carthagent Cloud');
|
|
13
|
+
assert.equal(nativeProviderName('cloud'), 'Carthagent Cloud');
|
|
14
|
+
assert.equal(nativeLoginProvider('claudecode').id, 'anthropic');
|
|
15
|
+
assert.equal(nativeLoginProvider('codex').id, 'openai');
|
|
16
|
+
assert.equal(nativeLoginProvider('gemini').id, 'google');
|
|
17
|
+
assert.equal(nativeProviderName('xai-code'), 'xAI / Grok');
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test('curated login rows preserve actual methods and do not invent subscription support', () => {
|
|
21
|
+
const anthropic = { id: 'anthropic', name: 'Anthropic', methods: [{ type: 'key' }] };
|
|
22
|
+
const ship = { id: 'experiential-labs', name: 'internal', methods: [{ type: 'oauth', id: 'carthagent-device' }] };
|
|
23
|
+
const rows = nativeLoginProviders([{ id: 'opencode', methods: [] }, anthropic, ship]);
|
|
24
|
+
assert.deepEqual(rows.map(row => row.id), ['experiential-labs', 'anthropic']);
|
|
25
|
+
assert.equal(rows[0].name, 'Carthagent Cloud');
|
|
26
|
+
assert.deepEqual(rows[1].methods, [{ type: 'key' }]);
|
|
27
|
+
assert.equal(anthropic.name, 'Anthropic');
|
|
28
|
+
assert.equal(ship.name, 'internal');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('native CLI help uses Carthagent branding', () => {
|
|
32
|
+
assert.doesNotMatch(cliHelp('test'), /opencode/i);
|
|
33
|
+
});
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { mkdtemp, readFile, access, writeFile, chmod } from 'node:fs/promises';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { spawnSync } from 'node:child_process';
|
|
7
|
+
import test from 'node:test';
|
|
8
|
+
import { selectNativePtyRuntime } from '../tools/native-pty-runtime.mjs';
|
|
9
|
+
import { nativePtySmokeArgs } from '../tools/native-pty-invocation.mjs';
|
|
10
|
+
|
|
11
|
+
test('installed PTY invocation carries an independently resolved upstream digest to Python', async () => {
|
|
12
|
+
const binary = process.execPath;
|
|
13
|
+
const digest = createHash('sha256').update(await readFile(binary)).digest('hex');
|
|
14
|
+
const selection = await selectNativePtyRuntime({
|
|
15
|
+
runtime: {
|
|
16
|
+
async resolveOpenCode() { return binary; },
|
|
17
|
+
async runtimeIdentity() { return { kind: 'upstream-compatible', binaryPath: binary, binarySha256: digest, version: '2.0.32' }; },
|
|
18
|
+
},
|
|
19
|
+
identityTools: {
|
|
20
|
+
async loadNativeTestRuntime() { return { runtimeVersion: '2.0.32' }; },
|
|
21
|
+
},
|
|
22
|
+
env: {},
|
|
23
|
+
});
|
|
24
|
+
const args = nativePtySmokeArgs({
|
|
25
|
+
root: '/package', binary, plugin: '/package/plugins/carthagent',
|
|
26
|
+
selection,
|
|
27
|
+
probe: { command: '/theme', commandArgs: 'carthage', expect: 'Theme saved', initialTheme: 'opencode', color: '213,175,102', dismissCompletion: true, postSubmitMarker: 'restart', capture: '/tmp/pty.raw', nodeExecutable: '/usr/bin/node', expectedTheme: 'carthage', expectStartupColor: true, writeInitialTheme: true, stateDir: '/tmp/theme-state', reuseState: true },
|
|
28
|
+
});
|
|
29
|
+
const digestArg = args.indexOf('--expected-sha256');
|
|
30
|
+
assert.notEqual(digestArg, -1, 'upstream-compatible binaries must receive the digest argument too');
|
|
31
|
+
assert.equal(args[digestArg + 1], digest);
|
|
32
|
+
assert.ok(args.includes('--initial-theme') && args.includes('carthage'));
|
|
33
|
+
assert.ok(args.includes('--expect-color') && args.includes('213,175,102'));
|
|
34
|
+
assert.ok(args.includes('--dismiss-completion') && args.includes('--post-submit-marker') && args.includes('restart'));
|
|
35
|
+
assert.equal(args[args.indexOf('--capture') + 1], '/tmp/pty.raw');
|
|
36
|
+
for (const [flag, value] of [['--node-executable', '/usr/bin/node'], ['--command-args', 'carthage'], ['--expect-theme', 'carthage'], ['--state-dir', '/tmp/theme-state']]) {
|
|
37
|
+
assert.equal(args[args.indexOf(flag) + 1], value, `${flag} value is preserved`);
|
|
38
|
+
}
|
|
39
|
+
assert.ok(args.includes('--expect-startup-color') && args.includes('--write-initial-theme') && args.includes('--reuse-state'));
|
|
40
|
+
const actual = createHash('sha256').update(await readFile(args[args.indexOf('--binary') + 1])).digest('hex');
|
|
41
|
+
assert.equal(actual, args[digestArg + 1], 'Python entrypoint is given the exact digest verified by runtime selection');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('owned runtime selection exposes its independently verified digest to the required Python argument', async () => {
|
|
45
|
+
const binary = '/fixture/owned/cli-linux-x64/bin/opencode';
|
|
46
|
+
const digest = 'b'.repeat(64);
|
|
47
|
+
const selection = await selectNativePtyRuntime({
|
|
48
|
+
runtime: {
|
|
49
|
+
async resolveOpenCode() { return binary; },
|
|
50
|
+
async runtimeIdentity() { return { kind: 'ctg-owned', binaryPath: binary, binarySha256: digest, sourceRevision: 'a'.repeat(40), patchSha256: 'c'.repeat(64), lockSha256: 'd'.repeat(64), platform: 'linux-x64' }; },
|
|
51
|
+
},
|
|
52
|
+
env: { CTG_TEST_RUNTIME_ARTIFACT_ROOT: '/fixture/owned', CTG_TEST_RUNTIME_RELEASE_MANIFEST: '/trusted/release.json' },
|
|
53
|
+
identityTools: {
|
|
54
|
+
async preflightNativeTestRuntimeArtifact() { return { expectedManifest: { pinned: true } }; },
|
|
55
|
+
async loadNativeTestRuntime() { return { binarySha256: digest, sourceRevision: 'a'.repeat(40), patchSha256: 'c'.repeat(64), lockSha256: 'd'.repeat(64) }; },
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
assert.equal(selection.binarySha256, digest);
|
|
59
|
+
const args = nativePtySmokeArgs({ root: '/package', binary, plugin: '/package/plugins/carthagent', selection, probe: { command: '/ctg-status', expect: 'OpenCode 2.0.32', secondEnter: true, initialTheme: 'carthage' } });
|
|
60
|
+
assert.equal(args[args.indexOf('--expected-sha256') + 1], digest);
|
|
61
|
+
assert.ok(args.includes('--second-enter') && args.includes('--initial-theme') && args.includes('carthage'));
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('invocation helper fails closed if the selected runtime omitted the digest', () => {
|
|
65
|
+
assert.throws(() => nativePtySmokeArgs({ root: '/package', binary: '/bin/opencode', plugin: '/plugin', selection: {}, probe: { command: '/ctg-status', expect: 'OpenCode' } }), /missing its independently verified/);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const pythonCandidates = [
|
|
69
|
+
process.env.PYTHON,
|
|
70
|
+
join(process.cwd(), '.venv', process.platform === 'win32' ? 'Scripts/python.exe' : 'bin/python'),
|
|
71
|
+
process.platform === 'win32' ? 'python.exe' : 'python3',
|
|
72
|
+
'python',
|
|
73
|
+
].filter(Boolean);
|
|
74
|
+
const pythonForPtyTest = pythonCandidates.find((candidate) => {
|
|
75
|
+
const result = spawnSync(candidate, ['--version'], { stdio: 'ignore' });
|
|
76
|
+
return !result.error && result.status === 0;
|
|
77
|
+
});
|
|
78
|
+
const pythonPtyBoundarySkip = process.platform === 'win32'
|
|
79
|
+
? 'The Python PTY preflight imports Unix-only fcntl; native PTY acceptance is Unix-only.'
|
|
80
|
+
: !pythonForPtyTest
|
|
81
|
+
? 'No configured or standard Python interpreter is available.'
|
|
82
|
+
: false;
|
|
83
|
+
|
|
84
|
+
test('Python digest preflight rejects changed bytes before executing the selected binary', { skip: pythonPtyBoundarySkip }, async () => {
|
|
85
|
+
const directory = await mkdtemp(join(tmpdir(), 'ctg-pty-preflight-'));
|
|
86
|
+
const binary = join(directory, 'binary');
|
|
87
|
+
const marker = join(directory, 'launched');
|
|
88
|
+
const python = join(process.cwd(), 'tools/native-pty-smoke.py');
|
|
89
|
+
try {
|
|
90
|
+
await writeFile(binary, `#!/bin/sh\ntouch ${JSON.stringify(marker)}\n`);
|
|
91
|
+
await chmod(binary, 0o755);
|
|
92
|
+
const result = spawnSync(pythonForPtyTest, [python, '--binary', binary, '--expected-sha256', '0'.repeat(64)], { encoding: 'utf8' });
|
|
93
|
+
assert.equal(result.status, 2, result.stderr);
|
|
94
|
+
assert.match(result.stderr, /SHA-256 changed before PTY execution/);
|
|
95
|
+
await assert.rejects(access(marker), { code: 'ENOENT' }, 'digest mismatch must be rejected before native binary launch');
|
|
96
|
+
} finally {
|
|
97
|
+
await import('node:fs/promises').then(({ rm }) => rm(directory, { recursive: true, force: true }));
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('explicit upstream digest mismatch fails before building a PTY invocation', async () => {
|
|
102
|
+
const binary = process.execPath;
|
|
103
|
+
const digest = createHash('sha256').update(await readFile(binary)).digest('hex');
|
|
104
|
+
await assert.rejects(selectNativePtyRuntime({
|
|
105
|
+
runtime: {
|
|
106
|
+
async resolveOpenCode() { return binary; },
|
|
107
|
+
async runtimeIdentity() { return { kind: 'upstream-compatible', binaryPath: binary, binarySha256: digest, version: '2.0.32' }; },
|
|
108
|
+
},
|
|
109
|
+
identityTools: {
|
|
110
|
+
async loadNativeTestRuntime() { return { runtimeVersion: '2.0.32' }; },
|
|
111
|
+
},
|
|
112
|
+
env: { CTG_NATIVE_PTY_EXPECTED_SHA256: '0'.repeat(64) },
|
|
113
|
+
}), /SHA-256 mismatch/);
|
|
114
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
import { resolve } from 'node:path';
|
|
4
|
+
import { selectNativePtyRuntime } from '../tools/native-pty-runtime.mjs';
|
|
5
|
+
|
|
6
|
+
test('injected owned artifact is independently preflighted before production runtime resolution', async () => {
|
|
7
|
+
const calls = [];
|
|
8
|
+
const runtime = {
|
|
9
|
+
async resolveOpenCode(options) { calls.push(['resolve', options]); return '/fixture/owned/cli-linux-x64/bin/opencode'; },
|
|
10
|
+
async runtimeIdentity() { calls.push(['identity']); return {}; },
|
|
11
|
+
};
|
|
12
|
+
const identityTools = {
|
|
13
|
+
async preflightNativeTestRuntimeArtifact(options) {
|
|
14
|
+
calls.push(['preflight', options]);
|
|
15
|
+
throw new Error('trusted digest mismatch');
|
|
16
|
+
},
|
|
17
|
+
async loadNativeTestRuntime() { calls.push(['verify']); },
|
|
18
|
+
};
|
|
19
|
+
await assert.rejects(selectNativePtyRuntime({
|
|
20
|
+
runtime,
|
|
21
|
+
env: { CTG_TEST_RUNTIME_ARTIFACT_ROOT: '/fixture/owned', CTG_TEST_RUNTIME_RELEASE_MANIFEST: '/trusted/release.json' },
|
|
22
|
+
identityTools,
|
|
23
|
+
}), /trusted digest mismatch/);
|
|
24
|
+
assert.deepEqual(calls.map(([operation]) => operation), ['preflight']);
|
|
25
|
+
assert.equal(calls[0][1].ownedRoot, resolve('/fixture/owned'));
|
|
26
|
+
assert.equal(calls[0][1].expectedManifestPath, '/trusted/release.json');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('ambient owned-runtime selector is rejected as an untrusted acceptance root', async () => {
|
|
30
|
+
let resolved = false;
|
|
31
|
+
await assert.rejects(selectNativePtyRuntime({
|
|
32
|
+
runtime: { async resolveOpenCode() { resolved = true; }, async runtimeIdentity() {} },
|
|
33
|
+
env: { CTG_OWNED_OPENCODE_RUNTIME: '/ambient/staging/runtime' },
|
|
34
|
+
}), /not an acceptance trust root/);
|
|
35
|
+
assert.equal(resolved, false);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('explicit developer binary requires an independently supplied digest before runtime resolution', async () => {
|
|
39
|
+
let resolved = false;
|
|
40
|
+
await assert.rejects(selectNativePtyRuntime({
|
|
41
|
+
runtime: { async resolveOpenCode() { resolved = true; }, async runtimeIdentity() {} },
|
|
42
|
+
env: { CTG_OPENCODE_BIN: '/missing/native/opencode' },
|
|
43
|
+
}), /requires CTG_NATIVE_PTY_EXPECTED_SHA256/);
|
|
44
|
+
assert.equal(resolved, false);
|
|
45
|
+
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
import { findNativeResumeSession } from '../plugins/carthagent/src/ui/resume-session.mjs';
|
|
4
|
+
|
|
5
|
+
test('resume finds ID prefixes outside the server title-search results', async () => {
|
|
6
|
+
const calls = [];
|
|
7
|
+
const byPrefix = { id: 'ses_abc123', title: 'An unrelated session title', time: { updated: 2 } };
|
|
8
|
+
const byTitle = { id: 'ses_xyz789', title: 'Parser implementation', time: { updated: 1 } };
|
|
9
|
+
const client = {
|
|
10
|
+
session: {
|
|
11
|
+
async get() { throw new Error('not an exact ID'); },
|
|
12
|
+
async list(input) {
|
|
13
|
+
calls.push(input);
|
|
14
|
+
const rows = input.search ? [byTitle] : [byPrefix];
|
|
15
|
+
return { data: rows, cursor: { next: null } };
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
assert.equal((await findNativeResumeSession({ query: 'ses_abc', directory: 'repo', client })).id, byPrefix.id);
|
|
20
|
+
assert.equal(calls[0].search, undefined, 'ID prefix lookup first uses unfiltered listing');
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test('resume falls back to title search after an unfiltered ID-prefix miss', async () => {
|
|
24
|
+
const calls = [];
|
|
25
|
+
const byTitle = { id: 'ses_xyz789', title: 'Parser implementation', time: { updated: 1 } };
|
|
26
|
+
const client = {
|
|
27
|
+
session: {
|
|
28
|
+
async get() { throw new Error('not an exact ID'); },
|
|
29
|
+
async list(input) {
|
|
30
|
+
calls.push(input);
|
|
31
|
+
return { data: input.search ? [byTitle] : [], cursor: { next: null } };
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
assert.equal((await findNativeResumeSession({ query: 'parser', directory: 'repo', client })).id, byTitle.id);
|
|
36
|
+
assert.equal(calls.length, 2);
|
|
37
|
+
assert.equal(calls[1].search, 'parser');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('resume rejects sessions from another project location', async () => {
|
|
41
|
+
const client = { session: {
|
|
42
|
+
get: async () => ({ id: 'ses_exact', location: { directory: 'other' } }),
|
|
43
|
+
list: async () => ({ data: [], cursor: { next: null } }),
|
|
44
|
+
} };
|
|
45
|
+
assert.equal(await findNativeResumeSession({ query: 'ses_exact', directory: 'repo', client }), undefined);
|
|
46
|
+
});
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
import { reviewKickoff } from '../lib/review.mjs';
|
|
4
|
+
import { createNativeReviewConsumer, nativeReviewMode, sendNativeReviewKickoff, subscribeNativeReviewEvents } from '../plugins/carthagent/src/ui/native-review.mjs';
|
|
5
|
+
|
|
6
|
+
const execution = (id, sessionID) => ({ id, data: { sessionID } });
|
|
7
|
+
const changed = [{ file: 'src/change.ts', additions: 1, deletions: 0, status: 'modified', patch: '@@ change' }];
|
|
8
|
+
|
|
9
|
+
function makeDiff() {
|
|
10
|
+
const calls = new Map();
|
|
11
|
+
return async (sessionID, signal) => {
|
|
12
|
+
signal?.throwIfAborted();
|
|
13
|
+
const count = (calls.get(sessionID) ?? 0) + 1;
|
|
14
|
+
calls.set(sessionID, count);
|
|
15
|
+
return count % 2 === 1 ? [] : changed;
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
test('native prompt payload is the legacy PR-review kickoff and only bypasses framing', async () => {
|
|
20
|
+
let request;
|
|
21
|
+
const signal = new AbortController().signal;
|
|
22
|
+
await sendNativeReviewKickoff({
|
|
23
|
+
client: { session: { async prompt(input, options) { request = { input, options }; } } },
|
|
24
|
+
sessionID: 'working-session', signal,
|
|
25
|
+
});
|
|
26
|
+
assert.equal(request.input.sessionID, 'working-session');
|
|
27
|
+
assert.equal(request.input.text, reviewKickoff());
|
|
28
|
+
assert.match(request.input.text, /gh pr create/);
|
|
29
|
+
assert.match(request.input.text, /ctg\.mjs" review <pr-number-or-url>/);
|
|
30
|
+
assert.deepEqual(request.input.metadata, { 'ctg.raw': true, 'ctg.reviewKickoff': true });
|
|
31
|
+
assert.equal(request.options.signal, signal);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('session explicit review mode overrides profile; launch mode overrides both', () => {
|
|
35
|
+
const session = { metadata: { 'carthagent.options': { review: 'yes' } } };
|
|
36
|
+
assert.equal(nativeReviewMode(session, { review: 'no' }), 'yes');
|
|
37
|
+
assert.equal(nativeReviewMode(session, { review: 'yes' }, 'no'), 'no');
|
|
38
|
+
assert.equal(nativeReviewMode({}, { review: 'no' }), 'no');
|
|
39
|
+
assert.equal(nativeReviewMode({}, {}), 'ask');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('ask confirmation is current-run-bound and one-shot; a later run invalidates the old offer', async () => {
|
|
43
|
+
const offers = [];
|
|
44
|
+
const kickoffs = [];
|
|
45
|
+
const consumer = createNativeReviewConsumer({
|
|
46
|
+
mode: () => 'ask', diff: makeDiff(), offer: async (offer) => offers.push(offer),
|
|
47
|
+
kickoff: async (sessionID) => kickoffs.push(sessionID),
|
|
48
|
+
});
|
|
49
|
+
consumer.onStarted(execution('run-1', 'session-a'));
|
|
50
|
+
await consumer.onSucceeded(execution('run-1', 'session-a'));
|
|
51
|
+
assert.equal(offers.length, 1);
|
|
52
|
+
consumer.onStarted(execution('run-2', 'session-a'));
|
|
53
|
+
assert.equal(await offers[0].start(), false, 'older confirmation cannot start after a new run');
|
|
54
|
+
await consumer.onSucceeded(execution('run-2', 'session-a'));
|
|
55
|
+
assert.equal(offers.length, 2);
|
|
56
|
+
assert.equal(await offers[1].start(), true);
|
|
57
|
+
assert.equal(await offers[1].start(), false, 'confirmation closure admits exactly once');
|
|
58
|
+
assert.deepEqual(kickoffs, ['session-a']);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('a run whose formal delivery is not reviewable is never offered or kicked off', async () => {
|
|
62
|
+
for (const mode of ['ask', 'yes']) {
|
|
63
|
+
const offers = [], kickoffs = [], asked = [];
|
|
64
|
+
const consumer = createNativeReviewConsumer({
|
|
65
|
+
mode: () => mode, diff: makeDiff(), offer: async (offer) => offers.push(offer),
|
|
66
|
+
eligible: async (sessionID) => { asked.push(sessionID); return false; },
|
|
67
|
+
kickoff: async (sessionID) => kickoffs.push(sessionID),
|
|
68
|
+
});
|
|
69
|
+
consumer.onStarted(execution('run-1', 'session-a'));
|
|
70
|
+
await consumer.onSucceeded(execution('run-1', 'session-a'));
|
|
71
|
+
assert.deepEqual([offers.length, kickoffs.length, asked], [0, 0, ['session-a']], mode);
|
|
72
|
+
}
|
|
73
|
+
const offers = [];
|
|
74
|
+
const failing = createNativeReviewConsumer({ mode: () => 'ask', diff: makeDiff(), offer: async (offer) => offers.push(offer), eligible: async () => { throw new Error('rpc down'); }, kickoff: async () => {} });
|
|
75
|
+
failing.onStarted(execution('run-1', 'session-a'));
|
|
76
|
+
await failing.onSucceeded(execution('run-1', 'session-a'));
|
|
77
|
+
assert.equal(offers.length, 0, 'an unknown delivery state fails closed');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('yes kicks off only successful new-change runs and suppresses only that session loop', async () => {
|
|
81
|
+
const loops = new Set();
|
|
82
|
+
const started = [];
|
|
83
|
+
const consumer = createNativeReviewConsumer({
|
|
84
|
+
mode: () => 'yes', diff: makeDiff(), offer: async () => assert.fail('yes does not prompt'),
|
|
85
|
+
kickoff: async (sessionID) => started.push(sessionID),
|
|
86
|
+
isLoopActive: (sessionID) => loops.has(sessionID),
|
|
87
|
+
setLoopActive: (sessionID, active) => active ? loops.add(sessionID) : loops.delete(sessionID),
|
|
88
|
+
});
|
|
89
|
+
consumer.onStarted(execution('run-a1', 'session-a'));
|
|
90
|
+
consumer.onStarted(execution('run-b1', 'session-b'));
|
|
91
|
+
await Promise.all([
|
|
92
|
+
consumer.onSucceeded(execution('run-a1', 'session-a')),
|
|
93
|
+
consumer.onSucceeded(execution('run-b1', 'session-b')),
|
|
94
|
+
]);
|
|
95
|
+
assert.deepEqual(started.sort(), ['session-a', 'session-b'], 'one session loop must not suppress an unrelated session');
|
|
96
|
+
assert.ok(loops.has('session-a') && loops.has('session-b'));
|
|
97
|
+
consumer.onUserPromptAdmitted('session-a', { type: 'user', metadata: {} });
|
|
98
|
+
assert.equal(loops.has('session-a'), false, 'admitted user prompt exits the session loop state');
|
|
99
|
+
assert.equal(loops.has('session-b'), true, 'unrelated session loop state remains active');
|
|
100
|
+
consumer.onStarted(execution('run-a2', 'session-a'));
|
|
101
|
+
consumer.onStarted(execution('run-b2', 'session-b'));
|
|
102
|
+
await Promise.all([
|
|
103
|
+
consumer.onSucceeded(execution('run-a2', 'session-a')),
|
|
104
|
+
consumer.onSucceeded(execution('run-b2', 'session-b')),
|
|
105
|
+
]);
|
|
106
|
+
assert.equal(started.length, 3, 'only session-a may re-enter a loop after a new user prompt');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('inbox.delivered resets restored loop only for the admitted message, not auto-review kickoff', async () => {
|
|
110
|
+
const persisted = new Set(['session-restored', 'session-kickoff']);
|
|
111
|
+
const messages = new Map();
|
|
112
|
+
const consumer = createNativeReviewConsumer({
|
|
113
|
+
mode: () => 'no', diff: async () => [], offer: async () => {}, kickoff: async () => {},
|
|
114
|
+
isLoopActive: (sessionID) => persisted.has(sessionID),
|
|
115
|
+
setLoopActive: (sessionID, active) => active ? persisted.add(sessionID) : persisted.delete(sessionID),
|
|
116
|
+
});
|
|
117
|
+
const handlers = new Map();
|
|
118
|
+
const cleanups = subscribeNativeReviewEvents(
|
|
119
|
+
{ on(type, handler) { handlers.set(type, handler); return () => {}; } },
|
|
120
|
+
consumer,
|
|
121
|
+
assert.fail,
|
|
122
|
+
async (sessionID, messageID) => messages.get(`${sessionID}:${messageID}`),
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
messages.set('session-restored:user-2', { type: 'user', metadata: {} });
|
|
126
|
+
handlers.get('session.inbox.delivered')({ data: { sessionID: 'session-restored', inboxID: 'user-2' } });
|
|
127
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
128
|
+
assert.equal(persisted.has('session-restored'), false, 'restored session loop state is explicitly persisted as cleared');
|
|
129
|
+
messages.set('session-kickoff:review-1', { type: 'user', metadata: { 'ctg.raw': true, 'ctg.reviewKickoff': true } });
|
|
130
|
+
handlers.get('session.inbox.delivered')({ data: { sessionID: 'session-kickoff', inboxID: 'review-1' } });
|
|
131
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
132
|
+
assert.equal(persisted.has('session-kickoff'), true, 'review kickoff does not clear its own loop suppression');
|
|
133
|
+
|
|
134
|
+
cleanups.forEach((cleanup) => cleanup());
|
|
135
|
+
consumer.dispose();
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test('execution.started does not use stale cached message state; inbox deliveries distinguish kickoff then user and coalesced work', async () => {
|
|
139
|
+
const persisted = new Set(['session-a']);
|
|
140
|
+
const messages = new Map([
|
|
141
|
+
['session-a:kickoff', { type: 'user', metadata: { 'ctg.raw': true, 'ctg.reviewKickoff': true } }],
|
|
142
|
+
['session-a:user-1', { type: 'user', metadata: {} }],
|
|
143
|
+
['session-a:user-2', { type: 'user', metadata: {} }],
|
|
144
|
+
['session-a:assistant', { type: 'assistant', metadata: {} }],
|
|
145
|
+
]);
|
|
146
|
+
const writes = [];
|
|
147
|
+
const consumer = createNativeReviewConsumer({
|
|
148
|
+
mode: () => 'no', diff: async () => [], offer: async () => {}, kickoff: async () => {},
|
|
149
|
+
isLoopActive: (sessionID) => persisted.has(sessionID),
|
|
150
|
+
setLoopActive: (sessionID, active) => { writes.push([sessionID, active]); if (active) persisted.add(sessionID); else persisted.delete(sessionID); },
|
|
151
|
+
});
|
|
152
|
+
const handlers = new Map();
|
|
153
|
+
const cleanups = subscribeNativeReviewEvents(
|
|
154
|
+
{ on(type, handler) { handlers.set(type, handler); return () => {}; } }, consumer, assert.fail,
|
|
155
|
+
async (sessionID, messageID) => messages.get(`${sessionID}:${messageID}`),
|
|
156
|
+
);
|
|
157
|
+
// The execution-start lifecycle fires first; it must not inspect an old
|
|
158
|
+
// cached message and reset this restored loop.
|
|
159
|
+
handlers.get('session.execution.started')(execution('run-1', 'session-a'));
|
|
160
|
+
assert.deepEqual(writes, []);
|
|
161
|
+
handlers.get('session.inbox.delivered')({ data: { sessionID: 'session-a', inboxID: 'kickoff' } });
|
|
162
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
163
|
+
assert.deepEqual(writes, [], 'kickoff delivery preserves suppression');
|
|
164
|
+
|
|
165
|
+
// Multiple queued user messages are distinct admissions even if the host
|
|
166
|
+
// coalesces them into one later execution.started event.
|
|
167
|
+
handlers.get('session.inbox.delivered')({ data: { sessionID: 'session-a', inboxID: 'user-1' } });
|
|
168
|
+
handlers.get('session.inbox.delivered')({ data: { sessionID: 'session-a', inboxID: 'user-2' } });
|
|
169
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
170
|
+
assert.deepEqual(writes, [['session-a', false]], 'restored state is persisted cleared once on real user admission');
|
|
171
|
+
assert.equal(persisted.has('session-a'), false);
|
|
172
|
+
|
|
173
|
+
handlers.get('session.inbox.delivered')({ data: { sessionID: 'session-a', inboxID: 'assistant' } });
|
|
174
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
175
|
+
assert.deepEqual(writes, [['session-a', false]], 'non-user delivered message is ignored');
|
|
176
|
+
cleanups.forEach((cleanup) => cleanup());
|
|
177
|
+
consumer.dispose();
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test('terminal handling waits for exact inbox admission lookup before deciding loop suppression', async () => {
|
|
181
|
+
const persisted = new Set(['session-race']);
|
|
182
|
+
const kickoffs = [];
|
|
183
|
+
const handlers = new Map();
|
|
184
|
+
let resolveMessage;
|
|
185
|
+
const consumer = createNativeReviewConsumer({
|
|
186
|
+
mode: () => 'yes', diff: makeDiff(), offer: async () => assert.fail(),
|
|
187
|
+
kickoff: async (sessionID) => kickoffs.push(sessionID),
|
|
188
|
+
isLoopActive: (sessionID) => persisted.has(sessionID),
|
|
189
|
+
setLoopActive: (sessionID, active) => active ? persisted.add(sessionID) : persisted.delete(sessionID),
|
|
190
|
+
});
|
|
191
|
+
const cleanups = subscribeNativeReviewEvents(
|
|
192
|
+
{ on(type, handler) { handlers.set(type, handler); return () => {}; } }, consumer, assert.fail,
|
|
193
|
+
async (_sessionID, _messageID, lookupSignal) => await new Promise((resolve) => {
|
|
194
|
+
resolveMessage = resolve;
|
|
195
|
+
lookupSignal.addEventListener('abort', () => resolve(undefined), { once: true });
|
|
196
|
+
}),
|
|
197
|
+
);
|
|
198
|
+
handlers.get('session.execution.started')(execution('run-race', 'session-race'));
|
|
199
|
+
const admission = handlers.get('session.inbox.delivered')({ data: { sessionID: 'session-race', inboxID: 'admitted-user' } });
|
|
200
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
201
|
+
const terminal = handlers.get('session.execution.succeeded')(execution('run-race', 'session-race'));
|
|
202
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
203
|
+
assert.deepEqual(kickoffs, [], 'success waits while admitted-message metadata is unresolved');
|
|
204
|
+
resolveMessage({ type: 'user', metadata: {} });
|
|
205
|
+
await admission;
|
|
206
|
+
await terminal;
|
|
207
|
+
assert.equal(kickoffs.length, 1, 'the admitted user prompt clears restored suppression before successful-run handling');
|
|
208
|
+
assert.equal(persisted.has('session-race'), true, 'the changed successful run starts its next review loop');
|
|
209
|
+
cleanups.forEach((cleanup) => cleanup());
|
|
210
|
+
consumer.dispose();
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test('inbox lookup queue preserves event order, times out, and disposal cancels pending metadata lookup', async () => {
|
|
214
|
+
const loops = new Set(['session-ordered', 'session-disposed']);
|
|
215
|
+
const handlers = new Map();
|
|
216
|
+
const requested = [];
|
|
217
|
+
let resolveStale;
|
|
218
|
+
const consumer = createNativeReviewConsumer({
|
|
219
|
+
mode: () => 'no', diff: async () => [], offer: async () => {}, kickoff: async () => {},
|
|
220
|
+
isLoopActive: (sessionID) => loops.has(sessionID),
|
|
221
|
+
setLoopActive: (sessionID, active) => active ? loops.add(sessionID) : loops.delete(sessionID),
|
|
222
|
+
});
|
|
223
|
+
const cleanups = subscribeNativeReviewEvents(
|
|
224
|
+
{ on(type, handler) { handlers.set(type, handler); return () => {}; } }, consumer, assert.fail,
|
|
225
|
+
async (_sessionID, messageID, signal) => {
|
|
226
|
+
requested.push(messageID);
|
|
227
|
+
if (messageID === 'stale-slow') return await new Promise((resolve) => { resolveStale = resolve; });
|
|
228
|
+
if (messageID === 'kickoff-slow') await new Promise((resolve) => setTimeout(resolve, 30));
|
|
229
|
+
if (messageID === 'pending') await new Promise((resolve) => setTimeout(resolve, 50));
|
|
230
|
+
signal.throwIfAborted();
|
|
231
|
+
return { type: 'user', metadata: messageID === 'kickoff-slow' ? { 'ctg.reviewKickoff': true } : {} };
|
|
232
|
+
},
|
|
233
|
+
);
|
|
234
|
+
handlers.get('session.inbox.delivered')({ data: { sessionID: 'session-ordered', inboxID: 'kickoff-slow' } });
|
|
235
|
+
handlers.get('session.inbox.delivered')({ data: { sessionID: 'session-ordered', inboxID: 'normal-next' } });
|
|
236
|
+
await new Promise((resolve) => setTimeout(resolve, 60));
|
|
237
|
+
assert.deepEqual(requested.slice(0, 2), ['kickoff-slow', 'normal-next']);
|
|
238
|
+
assert.equal(loops.has('session-ordered'), false, 'later normal prompt wins after earlier kickoff lookup completes');
|
|
239
|
+
|
|
240
|
+
const staleLookup = handlers.get('session.inbox.delivered')({ data: { sessionID: 'session-stale', inboxID: 'stale-slow' } });
|
|
241
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
242
|
+
consumer.setLoopActive('session-stale', true);
|
|
243
|
+
resolveStale({ type: 'user', metadata: {} });
|
|
244
|
+
await staleLookup;
|
|
245
|
+
assert.equal(loops.has('session-stale'), true, 'a lookup started before a newer loop generation cannot clear it');
|
|
246
|
+
|
|
247
|
+
const pendingLookup = handlers.get('session.inbox.delivered')({ data: { sessionID: 'session-disposed', inboxID: 'pending' } });
|
|
248
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
249
|
+
consumer.dispose();
|
|
250
|
+
await pendingLookup;
|
|
251
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
252
|
+
assert.equal(loops.has('session-disposed'), true, 'a lookup completing after disposal cannot mutate persisted state');
|
|
253
|
+
cleanups.forEach((cleanup) => cleanup());
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
test('failed/interrupted and unchanged executions do not launch a workflow', async () => {
|
|
257
|
+
const starts = [];
|
|
258
|
+
const consumer = createNativeReviewConsumer({
|
|
259
|
+
mode: () => 'yes', diff: async () => [], offer: async () => assert.fail(),
|
|
260
|
+
kickoff: async (sessionID) => starts.push(sessionID),
|
|
261
|
+
});
|
|
262
|
+
consumer.onStarted(execution('failed', 'failed'));
|
|
263
|
+
await consumer.onFailed(execution('failed', 'failed'));
|
|
264
|
+
consumer.onStarted(execution('interrupted', 'interrupted'));
|
|
265
|
+
await consumer.onInterrupted(execution('interrupted', 'interrupted'));
|
|
266
|
+
consumer.onStarted(execution('unchanged', 'unchanged'));
|
|
267
|
+
await consumer.onSucceeded(execution('unchanged', 'unchanged'));
|
|
268
|
+
assert.deepEqual(starts, []);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
test('host execution subscriptions are disposed and plugin shutdown blocks pending review admission', async () => {
|
|
272
|
+
const handlers = new Map();
|
|
273
|
+
const unsubscribed = [];
|
|
274
|
+
const data = { on(type, handler) { handlers.set(type, handler); return () => unsubscribed.push(type); } };
|
|
275
|
+
const offers = [];
|
|
276
|
+
const consumer = createNativeReviewConsumer({ mode: () => 'ask', diff: makeDiff(), offer: async (value) => offers.push(value), kickoff: async () => {} });
|
|
277
|
+
const cleanups = subscribeNativeReviewEvents(data, consumer);
|
|
278
|
+
handlers.get('session.execution.started')(execution('run', 'session'));
|
|
279
|
+
handlers.get('session.execution.succeeded')(execution('run', 'session'));
|
|
280
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
281
|
+
assert.equal(offers.length, 1);
|
|
282
|
+
consumer.dispose();
|
|
283
|
+
assert.equal(await offers[0].start(), false);
|
|
284
|
+
cleanups.forEach((cleanup) => cleanup());
|
|
285
|
+
assert.equal(unsubscribed.length, 5);
|
|
286
|
+
});
|