carthagent 1.0.7 → 1.1.0-preview.12

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.
Files changed (275) hide show
  1. package/README.md +80 -79
  2. package/bin/ctg.mjs +14 -732
  3. package/docs/glyph-review/host-chrome-glyph-compatibility-v4.md +83 -0
  4. package/docs/glyph-review/test-isolation-v1.md +26 -0
  5. package/docs/glyph-review/typecheck-base.log +1 -0
  6. package/docs/glyph-review/typecheck-v4.log +1 -0
  7. package/docs/glyph-review/unicode-inventory-v4.csv +402 -0
  8. package/docs/help-dispatch-review.md +69 -0
  9. package/docs/legacy-cli.md +150 -0
  10. package/docs/live-theme-plugin-api.md +18 -0
  11. package/docs/native-cli.md +102 -0
  12. package/docs/native-compatibility.md +16 -0
  13. package/docs/native-daily-quests.md +132 -0
  14. package/docs/native-glyph-pty.md +59 -0
  15. package/docs/native-manual-testing.md +314 -0
  16. package/docs/native-slash-acceptance-handoff.md +49 -0
  17. package/docs/native-terminal-paired-failure-2026-10-17.md +40 -0
  18. package/docs/native-terminal-performance.md +310 -0
  19. package/docs/native-terminal-semantics-evidence/README.md +25 -0
  20. package/docs/native-terminal-semantics-evidence/controlled-host-model-selftest.json +16 -0
  21. package/docs/native-terminal-semantics-evidence/current-acceptance-status.json +59 -0
  22. package/docs/native-terminal-semantics-evidence/final-binary-focused-85bb44a.json +154 -0
  23. package/docs/native-terminal-semantics-evidence/final-binary-full-incomplete-85bb44a.json +160 -0
  24. package/docs/native-terminal-semantics-evidence/final-binary-layout-85bb44a.json +149 -0
  25. package/docs/native-terminal-semantics-evidence/final-binary-layout-interactive-85bb44a.json +159 -0
  26. package/docs/native-terminal-semantics-evidence/final-binary-model-picker-escape-unchanged-85bb44a.json +44 -0
  27. package/docs/native-terminal-semantics-evidence/final-binary-model-picker-exact-identity-85bb44a.json +47 -0
  28. package/docs/native-terminal-semantics-evidence/final-binary-model-picker-unchanged-model-incomplete-85bb44a.json +58 -0
  29. package/docs/native-terminal-semantics-evidence/final-binary-model-selftest.json +16 -0
  30. package/docs/native-terminal-semantics-evidence/final-binary-picker-cancel-incomplete-85bb44a.json +62 -0
  31. package/docs/native-terminal-semantics-evidence/focused-clipboard-title-stale-binary.json +161 -0
  32. package/docs/native-terminal-semantics-evidence/layout-stale-binary-observation-not-accepted.json +153 -0
  33. package/docs/native-terminal-semantics-evidence/picker-latest-incomplete-attempt.json +62 -0
  34. package/docs/native-terminal-semantics-evidence/picker-previous-probe-pass.json +61 -0
  35. package/docs/native-terminal-semantics.md +89 -0
  36. package/docs/native-upgrade.md +177 -0
  37. package/docs/opencode-composed-source-review.md +89 -0
  38. package/docs/opencode-migration.md +225 -9
  39. package/docs/opencode-owned-runtime.md +241 -0
  40. package/docs/opencode-runtime.md +12 -0
  41. package/docs/opencode-theme-provider-proof/README.md +55 -0
  42. package/docs/opencode-theme-provider-proof/live-theme-api-base.patch +122 -0
  43. package/docs/opencode-theme-provider-proof/plugin-theme-provider.patch +107 -0
  44. package/docs/opencode-theme-provider-proof/session-ui-base.patch +1217 -0
  45. package/docs/owned-host-controls-proof.md +39 -0
  46. package/docs/owned-runtime-release-manifest.md +42 -0
  47. package/docs/owned-visual-clear-proof.md +40 -0
  48. package/docs/patches/opencode-v2.0.32-glyph-spinner-test-isolation-v1.patch +50 -0
  49. package/docs/patches/opencode-v2.0.32-glyphs.patch +3670 -0
  50. package/docs/patches/opencode-v2.0.32-help-dispatch.patch +156 -0
  51. package/docs/patches/opencode-v2.0.32-live-theme-api.patch +122 -0
  52. package/docs/patches/opencode-v2.0.32-session-ui-combined-v1.patch +5272 -0
  53. package/docs/patches/opencode-v2.0.32-session-ui-combined-v2.patch +6052 -0
  54. package/docs/patches/opencode-v2.0.32-session-ui-combined-v3.patch +8657 -0
  55. package/docs/patches/opencode-v2.0.32-session-ui-combined-v4.patch +9928 -0
  56. package/docs/patches/opencode-v2.0.32-session-ui.patch +5137 -0
  57. package/docs/release-notes-native-draft.md +73 -0
  58. package/docs/release-notes-preview.10.md +121 -0
  59. package/docs/release-notes-preview.11.md +76 -0
  60. package/docs/release-notes-preview.12.md +55 -0
  61. package/docs/release-notes-preview.9.md +130 -0
  62. package/evaluate.mjs +28 -1
  63. package/lib/budget.mjs +1 -1
  64. package/lib/codegraph.mjs +1 -1
  65. package/lib/delivery.mjs +3 -2
  66. package/lib/environment.mjs +8 -0
  67. package/lib/guidance.mjs +1 -1
  68. package/lib/opencode/cli-runtime.mjs +1171 -0
  69. package/lib/opencode/cli.mjs +252 -0
  70. package/lib/opencode/credentials.mjs +63 -8
  71. package/lib/opencode/demo.mjs +53 -0
  72. package/lib/opencode/evaluation-budget-plugin/index.ts +3 -0
  73. package/lib/opencode/evaluation.mjs +32 -12
  74. package/lib/opencode/headless.mjs +8 -4
  75. package/lib/opencode/provider-policy.mjs +23 -0
  76. package/lib/opencode/review.mjs +37 -16
  77. package/lib/opencode/runtime-artifact.mjs +335 -0
  78. package/lib/opencode/runtime-github-transport.mjs +121 -0
  79. package/lib/opencode/runtime-release.json +36 -0
  80. package/lib/opencode/runtime.mjs +298 -20
  81. package/lib/opencode/service.mjs +77 -35
  82. package/lib/opencode/session-options.mjs +6 -2
  83. package/lib/opencode/update.mjs +222 -0
  84. package/lib/opencode/workspace.mjs +1 -1
  85. package/lib/planning-advice.mjs +2 -2
  86. package/lib/tui/framing.mjs +1 -80
  87. package/lib/update.mjs +10 -1
  88. package/package.json +33 -7
  89. package/plugins/carthagent/README.md +23 -0
  90. package/plugins/carthagent/src/bootstrap.ts +1 -1
  91. package/plugins/carthagent/src/daily-evidence.mjs +57 -0
  92. package/plugins/carthagent/src/daily-quests-rpc.ts +50 -0
  93. package/plugins/carthagent/src/daily-source-rpc.ts +52 -0
  94. package/plugins/carthagent/src/index.ts +71 -24
  95. package/plugins/carthagent/src/rpc.ts +51 -6
  96. package/plugins/carthagent/src/server/codegraph.ts +12 -5
  97. package/plugins/carthagent/src/server/daily-quests-rpc.ts +277 -0
  98. package/plugins/carthagent/src/server/daily-source-rpc.ts +197 -0
  99. package/plugins/carthagent/src/server/delivery.ts +217 -106
  100. package/plugins/carthagent/src/server/framing.mjs +80 -0
  101. package/plugins/carthagent/src/server/policy.ts +1 -1
  102. package/plugins/carthagent/src/server/providers.ts +58 -30
  103. package/plugins/carthagent/src/server/session-controls.ts +3 -1
  104. package/plugins/carthagent/src/server/ship-agents.ts +77 -0
  105. package/plugins/carthagent/src/server/ship-context.mjs +60 -0
  106. package/plugins/carthagent/src/server/ship-guidance.ts +39 -0
  107. package/plugins/carthagent/src/server/ship-rpc.ts +69 -0
  108. package/plugins/carthagent/src/server/voyages-rpc.ts +223 -0
  109. package/plugins/carthagent/src/testing.ts +5 -0
  110. package/plugins/carthagent/src/tui.tsx +492 -0
  111. package/plugins/carthagent/src/ui/client.ts +125 -0
  112. package/plugins/carthagent/src/ui/commands.ts +70 -0
  113. package/plugins/carthagent/src/ui/daily-editor-focus.mjs +52 -0
  114. package/plugins/carthagent/src/ui/daily-editor.mjs +4 -0
  115. package/plugins/carthagent/src/ui/daily-quests-events.mjs +18 -0
  116. package/plugins/carthagent/src/ui/daily-quests-state.mjs +218 -0
  117. package/plugins/carthagent/src/ui/daily-quests.tsx +232 -0
  118. package/plugins/carthagent/src/ui/host-state.ts +42 -0
  119. package/plugins/carthagent/src/ui/native-navigation.ts +23 -0
  120. package/plugins/carthagent/src/ui/native-review.d.mts +31 -0
  121. package/plugins/carthagent/src/ui/native-review.mjs +154 -0
  122. package/plugins/carthagent/src/ui/resume-session.mjs +34 -0
  123. package/plugins/carthagent/src/ui/session-preferences.d.mts +7 -0
  124. package/plugins/carthagent/src/ui/session-preferences.mjs +14 -0
  125. package/plugins/carthagent/src/ui/theme-provision.d.mts +3 -0
  126. package/plugins/carthagent/src/ui/theme-provision.mjs +134 -0
  127. package/plugins/carthagent/src/ui/voyages-navigation-state.mjs +56 -0
  128. package/plugins/carthagent/src/ui/voyages-navigation.tsx +122 -0
  129. package/plugins/carthagent/src/ui/voyages-state.mjs +142 -0
  130. package/plugins/carthagent/src/ui/voyages.tsx +127 -0
  131. package/plugins/carthagent/src/ui/workspace-live.ts +61 -0
  132. package/plugins/carthagent/src/ui/workspace.tsx +379 -0
  133. package/plugins/carthagent/src/voyages-rpc.ts +60 -0
  134. package/plugins/carthagent/src/workspace-rpc.ts +36 -0
  135. package/plugins/carthagent/tests/commands.test.ts +107 -0
  136. package/plugins/carthagent/tests/session-preferences.test.mjs +41 -0
  137. package/plugins/carthagent/tests/voyages-navigation.test.tsx +258 -0
  138. package/plugins/carthagent/themes/carthage.json +67 -0
  139. package/plugins/carthagent/tui.ts +26 -1
  140. package/script/ctg/build-owned-runtime.sh +166 -0
  141. package/tests/budget-engine.test.mjs +4 -3
  142. package/tests/cli.test.mjs +52 -96
  143. package/tests/daily-evidence.test.mjs +62 -0
  144. package/tests/delivery.test.mjs +22 -1
  145. package/tests/fixtures/native-discovery-plugin/server.ts +15 -0
  146. package/tests/generate-owned-runtime-release.test.mjs +354 -0
  147. package/tests/installed-runtime-contract.test.mjs +43 -0
  148. package/tests/native-cli-host-smoke.mjs +457 -0
  149. package/tests/native-daily-editor-focus.test.mjs +213 -0
  150. package/tests/native-daily-editor.test.mjs +43 -0
  151. package/tests/native-daily-quests-layout.test.mjs +53 -0
  152. package/tests/native-daily-quests-rpc.test.mjs +456 -0
  153. package/tests/native-daily-quests-ui.test.mjs +778 -0
  154. package/tests/native-daily-source.test.mjs +426 -0
  155. package/tests/native-demo-ui.test.mjs +48 -0
  156. package/tests/native-evaluation-admission.test.mjs +24 -0
  157. package/tests/native-fixture-browser-cleanup.test.mjs +65 -0
  158. package/tests/native-fixture-runtime.test.mjs +111 -0
  159. package/tests/native-fixture-stop-diagnostics.test.mjs +67 -0
  160. package/tests/native-glyph-pty.test.mjs +107 -0
  161. package/tests/native-manual-projects.test.mjs +170 -0
  162. package/tests/native-owned-runtime-env.test.mjs +140 -0
  163. package/tests/native-paired-guards.test.mjs +464 -0
  164. package/tests/native-provider-policy.test.mjs +31 -0
  165. package/tests/native-pty-invocation.test.mjs +114 -0
  166. package/tests/native-pty-runtime.test.mjs +45 -0
  167. package/tests/native-resume-session.test.mjs +46 -0
  168. package/tests/native-review-consumer.test.mjs +267 -0
  169. package/tests/native-runtime-identity.test.mjs +214 -0
  170. package/tests/native-ship-parity.test.mjs +526 -0
  171. package/tests/native-ship-plan.test.mjs +75 -0
  172. package/tests/native-ship-rpc.test.mjs +71 -0
  173. package/tests/native-ui-commands.test.mjs +6 -0
  174. package/tests/native-voyages-layout.test.mjs +28 -0
  175. package/tests/native-voyages-navigation.test.mjs +169 -0
  176. package/tests/native-voyages-rpc.test.mjs +395 -0
  177. package/tests/native-voyages-ui.test.mjs +277 -0
  178. package/tests/opencode-atomic-write.test.mjs +55 -0
  179. package/tests/opencode-cli.test.mjs +1312 -0
  180. package/tests/opencode-codegraph-plugin.test.mjs +47 -0
  181. package/tests/opencode-providers.test.mjs +245 -18
  182. package/tests/opencode-runtime.test.mjs +546 -8
  183. package/tests/opencode-session-controls.test.mjs +15 -0
  184. package/tests/opencode-ship-rpc.test.mjs +71 -0
  185. package/tests/opencode-update.test.mjs +475 -0
  186. package/tests/opencode-workspace.test.mjs +51 -2
  187. package/tests/owned-runtime-publish-workflow.test.mjs +477 -0
  188. package/tests/release-workflow.test.mjs +46 -0
  189. package/tests/runtime-artifact.test.mjs +269 -0
  190. package/tests/runtime-development-trust.test.mjs +230 -0
  191. package/tests/runtime-github-transport.test.mjs +132 -0
  192. package/tests/runtime-token-env.test.mjs +23 -0
  193. package/tests/test_native_terminal_probe.py +76 -0
  194. package/tests/test_native_terminal_semantics.py +232 -0
  195. package/tests/update.test.mjs +36 -0
  196. package/tests/verify-draft-runtime-gate.test.mjs +280 -0
  197. package/tests/verify-owned-runtime-release.test.mjs +371 -0
  198. package/tests/workspace-live.test.mjs +127 -0
  199. package/tools/benchmark/diagnose-request.mjs +59 -36
  200. package/tools/benchmark/eval-common.mjs +3 -3
  201. package/tools/benchmark/evaluators.test.mjs +4 -4
  202. package/tools/benchmark/guard.test.mjs +35 -2
  203. package/tools/benchmark/guard.ts +20 -7
  204. package/tools/benchmark/planning-startup.test.mjs +30 -34
  205. package/tools/benchmark/runner.mjs +12 -7
  206. package/tools/benchmark/runner.test.mjs +23 -7
  207. package/tools/benchmark/upstream-error.test.mjs +16 -2
  208. package/tools/generate-owned-runtime-release.mjs +217 -0
  209. package/tools/installed-runtime-contract.mjs +16 -0
  210. package/tools/manual-runtime-manifest.mjs +39 -0
  211. package/tools/native-fixture-browser-cleanup.mjs +59 -0
  212. package/tools/native-fixture-stop-diagnostics.mjs +43 -0
  213. package/tools/native-fixture.mjs +199 -40
  214. package/tools/native-glyph-helpers.py +127 -0
  215. package/tools/native-glyph-pty.py +144 -0
  216. package/tools/native-manual-projects.mjs +259 -0
  217. package/tools/native-manual.mjs +338 -0
  218. package/tools/native-model-picker-acceptance.py +306 -0
  219. package/tools/native-owned-runtime-env.sh +47 -0
  220. package/tools/native-paired-guards.mjs +282 -0
  221. package/tools/native-paired-manifest.mjs +72 -0
  222. package/tools/native-paired-orchestration.sh +51 -0
  223. package/tools/native-plugin-readiness.mjs +33 -0
  224. package/tools/native-plugin-readiness.test.mjs +53 -0
  225. package/tools/native-pty-invocation.mjs +26 -0
  226. package/tools/native-pty-runtime.mjs +83 -0
  227. package/tools/native-pty-smoke.py +147 -24
  228. package/tools/native-public-repo-shell-result.mjs +13 -0
  229. package/tools/native-public-repo-shell-result.test.mjs +28 -0
  230. package/tools/native-runtime-identity.mjs +201 -0
  231. package/tools/native-slash-acceptance-env.py +38 -0
  232. package/tools/native-terminal-semantics.py +876 -0
  233. package/tools/owned-shell-observer/index.ts +16 -0
  234. package/tools/owned-shell-observer/package.json +1 -0
  235. package/tools/owned-visual-clear-env.py +48 -0
  236. package/tools/owned-visual-clear-model.py +21 -0
  237. package/tools/pinned-owned-runtime-inputs.mjs +46 -0
  238. package/tools/prepare-native-manual.mjs +122 -0
  239. package/tools/run-native-terminal-paired.sh +403 -0
  240. package/tools/runtime-token-env.mjs +8 -0
  241. package/tools/seed-native-switch-fixture.mjs +79 -0
  242. package/tools/test-native-controls.mjs +3 -1
  243. package/tools/test-native-daily-quests.mjs +65 -0
  244. package/tools/test-native-default-cli.mjs +106 -0
  245. package/tools/test-native-delivery-host.mjs +100 -18
  246. package/tools/test-native-evaluation.mjs +7 -3
  247. package/tools/test-native-glyph-pty.mjs +94 -0
  248. package/tools/test-native-host.mjs +83 -61
  249. package/tools/test-native-outcomes.mjs +3 -1
  250. package/tools/test-native-package.mjs +206 -38
  251. package/tools/test-native-performance.mjs +26 -0
  252. package/tools/test-native-product.mjs +90 -0
  253. package/tools/test-native-pty.mjs +34 -9
  254. package/tools/test-native-public-repo.mjs +224 -0
  255. package/tools/test-native-refresh-reentry.mjs +106 -0
  256. package/tools/test-native-report.mjs +27 -20
  257. package/tools/test-native-review.mjs +75 -0
  258. package/tools/test-native-runtime.mjs +120 -0
  259. package/tools/test-native-ship-models.mjs +176 -0
  260. package/tools/test-native-ship-walkthrough.mjs +96 -0
  261. package/tools/test-native-slash-commands.mjs +125 -0
  262. package/tools/test-native-slash-commands.py +242 -0
  263. package/tools/test-native-swe-host.mjs +224 -55
  264. package/tools/test-native-tasks.mjs +77 -0
  265. package/tools/test-native-terminal-performance.py +887 -0
  266. package/tools/test-native-terminal-readiness.sh +85 -0
  267. package/tools/test-native-terminal-semantics-installed.mjs +38 -0
  268. package/tools/test-native-visual-clear-installed.mjs +48 -0
  269. package/tools/test-owned-host-controls.mjs +238 -0
  270. package/tools/test-owned-opencode-runtime.mjs +35 -0
  271. package/tools/test-owned-visual-clear-env.py +120 -0
  272. package/tools/test-owned-visual-clear.py +373 -0
  273. package/tools/verify-draft-runtime-gate.mjs +138 -0
  274. package/tools/verify-owned-runtime-release.mjs +114 -0
  275. package/tools/verify-release-version.mjs +21 -1
@@ -0,0 +1,338 @@
1
+ #!/usr/bin/env node
2
+ import { readFile, cp, mkdir, writeFile } from 'node:fs/promises';
3
+ import { spawn, execFileSync } from 'node:child_process';
4
+ import { once } from 'node:events';
5
+ import { resolve, join } from 'node:path';
6
+ import { pathToFileURL } from 'node:url';
7
+ import { nativeFixture, streamText } from './native-fixture.mjs';
8
+ import { preflightNativeTestRuntimeArtifact } from './native-runtime-identity.mjs';
9
+ import { readManualRuntimeManifest } from './manual-runtime-manifest.mjs';
10
+ import { provisionDefaultNativeTheme } from '../plugins/carthagent/src/ui/theme-provision.mjs';
11
+
12
+ const root = resolve(import.meta.dirname, '..');
13
+
14
+ export async function runManualKit(
15
+ directory,
16
+ project = 'tiny',
17
+ { live = false, smoke = false, args = [], stop = false, runtimeManifest, manifestSha256 } = {},
18
+ ) {
19
+ if (!['tiny', 'product', 'scale'].includes(project))
20
+ throw Error('Project must be tiny, product, or scale');
21
+ const kit = resolve(directory);
22
+ const record = JSON.parse(
23
+ await readFile(join(kit, 'manual-kit.json'), 'utf8'),
24
+ );
25
+ const { manifest, manifestPath, manifestSha256: actualDigest } = await readManualRuntimeManifest({ runtimeManifest, manifestSha256 });
26
+ const releaseVersion = manifest.releaseVersion;
27
+ const target = `${process.platform === 'win32' ? 'windows' : process.platform}-${process.arch}`;
28
+ if (
29
+ record.version !== 1 ||
30
+ record.target !== target ||
31
+ record.runtime !== `runtimes/${releaseVersion}/${target}`
32
+ )
33
+ throw Error(
34
+ 'Kit platform/layout does not match this machine; prepare a new native kit',
35
+ );
36
+ if (actualDigest !== record.manifestSha256)
37
+ throw Error('Kit manifest does not match this source checkout');
38
+ const ownedRoot = join(kit, record.runtime);
39
+ const { identity } = await preflightNativeTestRuntimeArtifact({
40
+ ownedRoot,
41
+ expectedManifestPath: manifestPath,
42
+ env: {},
43
+ });
44
+ if (identity.binarySha256 !== record.binarySha256)
45
+ throw Error('Kit runtime identity changed');
46
+ const safeEnv = Object.fromEntries(
47
+ [
48
+ 'PATH',
49
+ 'SystemRoot',
50
+ 'WINDIR',
51
+ 'COMSPEC',
52
+ 'PATHEXT',
53
+ 'LANG',
54
+ 'LC_ALL',
55
+ 'TERM',
56
+ 'COLORTERM',
57
+ ]
58
+ .filter((key) => process.env[key])
59
+ .map((key) => [key, process.env[key]]),
60
+ );
61
+ if (live) {
62
+ if (smoke)
63
+ throw Error('Live mode never runs automatically; use offline --smoke');
64
+ const profile = join(kit, 'profiles', 'live');
65
+ if (stop) {
66
+ const { stopService } = await import('../lib/opencode/service.mjs');
67
+ return await stopService({ root: profile });
68
+ }
69
+ const home = join(profile, 'home');
70
+ await mkdir(home, { recursive: true, mode: 0o700 });
71
+ const env = {
72
+ ...safeEnv,
73
+ HOME: home,
74
+ USERPROFILE: home,
75
+ APPDATA: join(home, 'AppData', 'Roaming'),
76
+ LOCALAPPDATA: join(home, 'AppData', 'Local'),
77
+ XDG_CONFIG_HOME: join(profile, 'config'),
78
+ XDG_DATA_HOME: join(profile, 'data'),
79
+ XDG_STATE_HOME: join(profile, 'state'),
80
+ XDG_CACHE_HOME: join(profile, 'cache'),
81
+ CTG_NATIVE: '1',
82
+ CTG_NODE_EXECUTABLE: process.execPath,
83
+ CTG_OWNED_OPENCODE_RUNTIME: ownedRoot,
84
+ CTG_OPENCODE_HOME: profile,
85
+ CARTHAGENT_CONFIG: join(profile, 'carthagent-config.json'),
86
+ CARTHAGENT_CODING_AGENT_DIR: join(profile, 'agent'),
87
+ };
88
+ for (const key of [
89
+ 'OPENAI_API_KEY',
90
+ 'ANTHROPIC_API_KEY',
91
+ 'OPENROUTER_API_KEY',
92
+ 'GEMINI_API_KEY',
93
+ ])
94
+ if (process.env[key]) env[key] = process.env[key];
95
+ console.log(
96
+ 'LIVE mode: prompts/source may leave this machine and provider use may incur charges. Profile:',
97
+ profile,
98
+ );
99
+ const child = spawn(
100
+ process.execPath,
101
+ [join(root, 'bin/ctg.mjs'), ...args],
102
+ { cwd: join(kit, 'projects', project), env, stdio: 'inherit' },
103
+ );
104
+ const [code, signal] = await once(child, 'exit');
105
+ if (signal) throw Error(`Native CLI exited with ${signal}`);
106
+ if (code !== 0) throw Error(`Native CLI exited with status ${code}`);
107
+ return;
108
+ }
109
+ if (stop || args.length)
110
+ throw Error('Extra CLI arguments and --stop require --live');
111
+ const runtimeKeys = [
112
+ 'CTG_OWNED_OPENCODE_RUNTIME',
113
+ 'CTG_TEST_RUNTIME_RELEASE_MANIFEST',
114
+ 'CTG_OPENCODE_BIN',
115
+ 'CTG_TEST_RUNTIME_SHA256',
116
+ ];
117
+ const previousEnvironment = Object.fromEntries(
118
+ runtimeKeys.map((key) => [key, process.env[key]]),
119
+ );
120
+ process.env.CTG_OWNED_OPENCODE_RUNTIME = ownedRoot;
121
+ process.env.CTG_TEST_RUNTIME_RELEASE_MANIFEST = manifestPath;
122
+ delete process.env.CTG_OPENCODE_BIN;
123
+ delete process.env.CTG_TEST_RUNTIME_SHA256;
124
+ let host;
125
+ let child;
126
+ try {
127
+ host = await nativeFixture({
128
+ verifyInventory: true,
129
+ env: { CTG_NODE_EXECUTABLE: process.execPath },
130
+ respond(body, res) {
131
+ const messages = body.messages ?? [];
132
+ const latest = [...messages]
133
+ .reverse()
134
+ .find((message) => message.role === 'user');
135
+ const text =
136
+ typeof latest?.content === 'string'
137
+ ? latest.content
138
+ : JSON.stringify(latest?.content ?? '');
139
+ if (text.includes('CTG_MANUAL_INSPECT')) {
140
+ const recent = messages.slice(
141
+ messages.findLastIndex((message) => message.role === 'user'),
142
+ );
143
+ const calls = recent.flatMap((message) => message.tool_calls ?? []);
144
+ const shell = calls.find((call) => call.id === 'manual-inspection');
145
+ const output =
146
+ shell &&
147
+ recent.find(
148
+ (message) =>
149
+ message.role === 'tool' && message.tool_call_id === shell.id,
150
+ );
151
+ if (output)
152
+ return streamText(
153
+ res,
154
+ `CTG_MANUAL_INSPECTION_COMPLETE\n${String(output.content).slice(-4000)}`,
155
+ );
156
+ const planned = calls.some((call) => call.id === 'manual-plan');
157
+ const name = planned ? 'shell' : 'execute';
158
+ if (!(body.tools ?? []).some((tool) => tool.function?.name === name))
159
+ return streamText(
160
+ res,
161
+ `CTG_MANUAL_POLICY_REJECTED: ${name} is not admitted. ${recent
162
+ .filter((message) => message.role === 'tool')
163
+ .map((message) => String(message.content))
164
+ .join('\n')}`,
165
+ );
166
+ const input = planned
167
+ ? {
168
+ command: `node --input-type=module -e "import {spawnSync} from 'node:child_process';const r=spawnSync(process.execPath,['--test','--test-reporter=tap','test/*.test.mjs'],{encoding:'utf8',maxBuffer:16000000});console.log(r.status===0?(r.stdout??'').split(String.fromCharCode(10)).filter(s=>s.startsWith('# ')).slice(-8).join(String.fromCharCode(10)):(r.stdout??'').slice(-12000));process.stderr.write(r.stderr??'');console.log('CTG_MANUAL_BASELINE_EXIT='+r.status);process.exit(r.status??1)"`,
169
+ workdir: host.cwd,
170
+ }
171
+ : {
172
+ code: `return await tools.carthagent["delivery_plan"](${JSON.stringify({ goal: 'Observe the generated project baseline without modifying source', assumptions: ['This is a scripted offline inspection, not implementation or independent review.'], artifacts: ['test', 'TASK.md', 'package.json'], outputs: ['Baseline test results'], steps: [{ id: 'inspect', title: 'Run the existing baseline', checks: ['baseline'] }], acceptance: [{ requirement: 'Existing baseline tests pass', checks: ['baseline'] }], checks: [{ id: 'baseline', kind: 'test', argv: [process.execPath, '--test', 'test/*.test.mjs'], timeoutSeconds: 30 }] })})`,
173
+ };
174
+ res.writeHead(200, { 'content-type': 'text/event-stream' });
175
+ const call = {
176
+ index: 0,
177
+ id: planned ? 'manual-inspection' : 'manual-plan',
178
+ type: 'function',
179
+ function: { name, arguments: JSON.stringify(input) },
180
+ };
181
+ res.end(
182
+ `data: ${JSON.stringify({ id: 'manual', object: 'chat.completion.chunk', created: 1, model: 'fixture', choices: [{ index: 0, delta: { role: 'assistant', tool_calls: [call] }, finish_reason: 'tool_calls' }] })}\n\ndata: [DONE]\n\n`,
183
+ );
184
+ return;
185
+ }
186
+ streamText(
187
+ res,
188
+ `CTG_MANUAL_REPLY\nLocal fixture response; this is not a reasoning model.\n${text.slice(-600)}\nUnicode payload: café Ελληνικά 日本語 🧪\nType CTG_MANUAL_INSPECT to run this project's baseline through a real native shell tool.`,
189
+ );
190
+ },
191
+ });
192
+ await cp(join(kit, 'projects', project), host.cwd, {
193
+ recursive: true,
194
+ filter: (source) =>
195
+ !source
196
+ .split(/[\\/]/)
197
+ .some((part) =>
198
+ ['.git', '.harness', 'node_modules', '.env'].includes(part),
199
+ ),
200
+ });
201
+ execFileSync('git', ['init', '-b', 'main'], {
202
+ cwd: host.cwd,
203
+ env: safeEnv,
204
+ stdio: 'pipe',
205
+ });
206
+ const session = await host.client.session.create(
207
+ {
208
+ location: host.location,
209
+ model: host.model,
210
+ title: `Manual ${project} fixture`,
211
+ metadata: {
212
+ 'ctg.raw': true,
213
+ 'carthagent.options': { guide: false, strict: false, review: 'no' },
214
+ },
215
+ },
216
+ host.request(),
217
+ );
218
+ await host.active();
219
+ await provisionDefaultNativeTheme(join(host.temp, 'config/opencode'));
220
+ const env = {
221
+ ...safeEnv,
222
+ HOME: join(host.temp, 'home'),
223
+ USERPROFILE: join(host.temp, 'home'),
224
+ XDG_CONFIG_HOME: join(host.temp, 'config'),
225
+ XDG_DATA_HOME: join(host.temp, 'data'),
226
+ XDG_STATE_HOME: join(host.temp, 'state'),
227
+ XDG_CACHE_HOME: join(host.temp, 'cache'),
228
+ OPENCODE_CONFIG_DIR: join(host.temp, 'config/opencode'),
229
+ OPENCODE_CONFIG_PROJECT_DISABLE: 'true',
230
+ OPENCODE_PASSWORD: host.password,
231
+ CTG_NODE_EXECUTABLE: process.execPath,
232
+ };
233
+ if (smoke) {
234
+ await host.client.session.prompt(
235
+ { sessionID: session.id, text: 'CTG_MANUAL_INSPECT' },
236
+ host.request(),
237
+ );
238
+ const outcome = await host.outcome(session.id);
239
+ if (outcome !== 'succeeded')
240
+ throw Error(`Native inspection ended with ${outcome}`);
241
+ const messages = await host.client.session.context(
242
+ { sessionID: session.id },
243
+ host.request(),
244
+ );
245
+ const rendered = JSON.stringify(messages);
246
+ if (
247
+ !rendered.includes('CTG_MANUAL_INSPECTION_COMPLETE') ||
248
+ !rendered.includes('CTG_MANUAL_BASELINE_EXIT=0')
249
+ )
250
+ throw Error(
251
+ 'Native project inspection did not finish with a successful correlated baseline tool result',
252
+ );
253
+ const report = {
254
+ project,
255
+ binarySha256: record.binarySha256,
256
+ scope:
257
+ 'Scripted offline provider; actual native plugin, shell and generated-project baseline',
258
+ outcome,
259
+ providerRequests: host.requests.length,
260
+ completed: true,
261
+ };
262
+ await mkdir(join(kit, 'reports'), { recursive: true });
263
+ await writeFile(
264
+ join(kit, 'reports', `${project}-native-smoke.json`),
265
+ JSON.stringify(report, null, 2) + '\n',
266
+ );
267
+ console.log(JSON.stringify(report, null, 2));
268
+ return report;
269
+ }
270
+ console.log(
271
+ `OFFLINE UI: disposable ${project} copy; no provider keys forwarded. This is not an OS sandbox.\nProfile disappears on exit. Do not use this mode to judge model quality.\nUse CTG_MANUAL_INSPECT, /help, /clear, /autocompact off, /workflow.\nProject: ${host.cwd}`,
272
+ );
273
+ child = spawn(
274
+ identity.binary,
275
+ ['--server', host.baseUrl, '--session', session.id],
276
+ { cwd: host.cwd, env, stdio: 'inherit' },
277
+ );
278
+ const [code, signal] = await once(child, 'exit');
279
+ if (signal) throw Error(`Native TUI exited with ${signal}`);
280
+ if (code !== 0) throw Error(`Native TUI exited with status ${code}`);
281
+ } catch (error) {
282
+ if (host)
283
+ console.error(
284
+ 'Offline fixture diagnostics:',
285
+ JSON.stringify({
286
+ providerErrors: host.providerErrors.map((error) => error.message),
287
+ requests: host.requests.length,
288
+ tools: host.requests
289
+ .at(-1)
290
+ ?.tools?.map((tool) => tool.function?.name),
291
+ lastToolResults: host.requests
292
+ .at(-1)
293
+ ?.messages?.filter((message) => message.role === 'tool')
294
+ .map((message) => String(message.content).slice(-2500)),
295
+ }),
296
+ );
297
+ throw error;
298
+ } finally {
299
+ if (child && child.exitCode === null && child.signalCode === null)
300
+ child.kill('SIGTERM');
301
+ try {
302
+ await host?.close();
303
+ } finally {
304
+ for (const key of runtimeKeys) {
305
+ if (previousEnvironment[key] === undefined) delete process.env[key];
306
+ else process.env[key] = previousEnvironment[key];
307
+ }
308
+ }
309
+ }
310
+ }
311
+
312
+ if (
313
+ process.argv[1] &&
314
+ import.meta.url === pathToFileURL(resolve(process.argv[1])).href
315
+ ) {
316
+ const [directory, project = 'tiny', ...options] = process.argv.slice(2);
317
+ if (!directory)
318
+ throw Error(
319
+ 'Usage: node tools/native-manual.mjs <kit> [tiny|product|scale] [--smoke | --live [-- <ctg arguments>]]',
320
+ );
321
+ const delimiter = options.indexOf('--');
322
+ const flags = delimiter < 0 ? options : options.slice(0, delimiter);
323
+ const manifestOptions = {};
324
+ for (let i = 0; i < flags.length; i++) {
325
+ if (['--live', '--smoke', '--stop'].includes(flags[i])) continue;
326
+ const flag = flags[i];
327
+ if (!['--manifest', '--manifest-sha256'].includes(flag) || !flags[i + 1] || flags[i + 1].startsWith('--'))
328
+ throw Error('Unknown or incomplete manual runner option');
329
+ manifestOptions[flag === '--manifest' ? 'runtimeManifest' : 'manifestSha256'] = flags[++i];
330
+ }
331
+ await runManualKit(directory, project, {
332
+ ...manifestOptions,
333
+ live: flags.includes('--live'),
334
+ smoke: flags.includes('--smoke'),
335
+ stop: flags.includes('--stop'),
336
+ args: delimiter < 0 ? [] : options.slice(delimiter + 1),
337
+ });
338
+ }
@@ -0,0 +1,306 @@
1
+ #!/usr/bin/env python3
2
+ """Focused native model-picker cancel proof using a local two-model provider."""
3
+ import argparse
4
+ import fcntl
5
+ import hashlib
6
+ import importlib.machinery
7
+ import importlib.util
8
+ import json
9
+ import os
10
+ from pathlib import Path
11
+ import pty
12
+ import re
13
+ import select
14
+ import signal
15
+ import struct
16
+ import subprocess
17
+ import termios
18
+ import tempfile
19
+ import time
20
+ from datetime import datetime, timezone
21
+
22
+
23
+ def sha256_file(path):
24
+ digest = hashlib.sha256()
25
+ with open(path, 'rb') as source:
26
+ for chunk in iter(lambda: source.read(1024 * 1024), b''):
27
+ digest.update(chunk)
28
+ return digest.hexdigest()
29
+
30
+
31
+ def option_style_bytes(transcript, option):
32
+ pattern = re.compile(rb'(?:\x1b\[[0-9;]*m)*' + re.escape(option.encode('ascii')))
33
+ matches = list(pattern.finditer(transcript))
34
+ return matches[-1].group(0)[:-len(option)].decode('ascii', errors='replace') if matches else None
35
+
36
+
37
+ def main():
38
+ parser = argparse.ArgumentParser()
39
+ parser.add_argument('--binary', default=os.environ.get('CTG_OPENCODE_BIN'))
40
+ parser.add_argument('--expected-version', required=True,
41
+ help='exact --version output expected for the trusted supplied executable')
42
+ parser.add_argument('--expected-sha256', required=True,
43
+ help='independently sourced expected SHA-256; binary is hashed before execution')
44
+ parser.add_argument('--timeout', type=int, default=60)
45
+ parser.add_argument('--artifact')
46
+ args = parser.parse_args()
47
+ if not args.binary:
48
+ parser.error('pass --binary or set CTG_OPENCODE_BIN')
49
+ binary = os.path.realpath(os.path.abspath(args.binary))
50
+ if not os.path.isfile(binary):
51
+ parser.error(f'binary not found: {binary}')
52
+ if not re.fullmatch(r'[0-9a-f]{64}', args.expected_sha256):
53
+ parser.error('--expected-sha256 must be 64 lowercase hexadecimal characters')
54
+ actual_sha256 = sha256_file(binary)
55
+ if actual_sha256 != args.expected_sha256:
56
+ parser.error(f'binary SHA-256 mismatch before execution: expected {args.expected_sha256}, got {actual_sha256}')
57
+ version = subprocess.run([binary, '--version'], text=True, capture_output=True, timeout=20)
58
+ version_output = (version.stdout + version.stderr).strip()
59
+ if version.returncode or version_output != args.expected_version:
60
+ parser.error(f'expected exact version {args.expected_version!r}, got {version_output!r}')
61
+
62
+ harness_path = Path(__file__).with_name('native-terminal-semantics.py')
63
+ loader = importlib.machinery.SourceFileLoader('native_terminal_semantics', str(harness_path))
64
+ spec = importlib.util.spec_from_loader(loader.name, loader)
65
+ harness = importlib.util.module_from_spec(spec)
66
+ loader.exec_module(harness)
67
+ provider = harness.Provider
68
+ screen_type = harness.Screen
69
+ provider.received.clear()
70
+ provider.reply = 'MODEL_PICKER_FIXTURE_REPLY'
71
+
72
+ source_root = Path(__file__).resolve().parents[1]
73
+ source_commit = subprocess.run(['git', '-C', str(source_root), 'rev-parse', 'HEAD'],
74
+ text=True, capture_output=True).stdout.strip()
75
+ dirty = subprocess.run(['git', '-C', str(source_root), 'status', '--porcelain',
76
+ '--untracked-files=no'], text=True, capture_output=True).stdout.strip()
77
+ result = {
78
+ 'startedAtUtc': datetime.now(timezone.utc).isoformat(),
79
+ 'case': 'native-model-picker-navigation-escape-unchanged-model',
80
+ 'binary': binary,
81
+ 'expectedBinaryVersion': args.expected_version,
82
+ 'binaryVersion': version_output,
83
+ 'expectedBinarySha256': args.expected_sha256,
84
+ 'binarySha256': actual_sha256,
85
+ 'sourceCommit': source_commit,
86
+ 'workingTreeDirty': bool(dirty),
87
+ 'probeSha256': sha256_file(__file__),
88
+ 'supportingHarnessSha256': sha256_file(harness_path),
89
+ 'provider': 'loopback offline fixture',
90
+ 'configuredModel': 'ctgfixture/fixture',
91
+ 'alternateModel': 'ctgfixture/alternate',
92
+ 'binaryPreflight': 'sha256 verified against required external expected value before --version or TUI launch',
93
+ 'physicalOSClipboardVerified': False,
94
+ 'checks': {},
95
+ }
96
+
97
+ server = __import__('socketserver').ThreadingTCPServer(('127.0.0.1', 0), provider)
98
+ import threading
99
+ threading.Thread(target=server.serve_forever, daemon=True).start()
100
+ master = slave = child = None
101
+ try:
102
+ with tempfile.TemporaryDirectory(prefix='ctg-model-picker-', ignore_cleanup_errors=True) as temp:
103
+ root = os.path.abspath(temp)
104
+ project = os.path.join(root, 'project')
105
+ config_dir = os.path.join(root, 'config', 'opencode')
106
+ for folder in (project, config_dir, os.path.join(root, 'home'),
107
+ os.path.join(root, 'data'), os.path.join(root, 'state'),
108
+ os.path.join(root, 'cache')):
109
+ os.makedirs(folder, exist_ok=True)
110
+ config = {
111
+ 'update': 'disable',
112
+ 'model': 'ctgfixture/fixture',
113
+ 'providers': {'ctgfixture': {
114
+ 'name': 'CTG Picker Fixture',
115
+ 'package': '@opencode/ai/providers/openai-compatible',
116
+ 'settings': {'baseURL': f'http://127.0.0.1:{server.server_address[1]}/v1'},
117
+ 'models': {
118
+ 'fixture': {'name': 'CTG Picker A', 'limit': {'context': 32000, 'output': 1024}},
119
+ 'alternate': {'name': 'CTG Picker B', 'limit': {'context': 32000, 'output': 1024}},
120
+ },
121
+ }},
122
+ }
123
+ config_path = os.path.join(config_dir, 'opencode.json')
124
+ Path(config_path).write_text(json.dumps(config), encoding='utf8')
125
+ env = {key: os.environ[key] for key in ('PATH', 'LANG', 'LC_ALL') if key in os.environ}
126
+ env.update({
127
+ 'HOME': os.path.join(root, 'home'), 'TERM': 'xterm-256color',
128
+ 'COLORTERM': 'truecolor', 'XDG_CONFIG_HOME': os.path.join(root, 'config'),
129
+ 'XDG_DATA_HOME': os.path.join(root, 'data'), 'XDG_STATE_HOME': os.path.join(root, 'state'),
130
+ 'XDG_CACHE_HOME': os.path.join(root, 'cache'), 'OPENCODE_DB': os.path.join(root, 'db.sqlite'),
131
+ 'OPENCODE_CONFIG_DIR': config_dir, 'OPENCODE_CONFIG': config_path,
132
+ 'OPENCODE_CONFIG_PROJECT_DISABLE': 'true',
133
+ })
134
+ master, slave = pty.openpty()
135
+ fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack('HHHH', 32, 100, 0, 0))
136
+ child = subprocess.Popen([binary, '--standalone'], cwd=project, env=env,
137
+ stdin=slave, stdout=slave, stderr=slave, start_new_session=True)
138
+ os.close(slave)
139
+ slave = None
140
+ screen = screen_type(rows=32, columns=100)
141
+ transcript = bytearray()
142
+
143
+ def pump(duration=.15):
144
+ deadline = time.monotonic() + duration
145
+ while time.monotonic() < deadline:
146
+ if child.poll() is not None:
147
+ raise RuntimeError(f'host exited early ({child.returncode}): {screen.visible()[-1000:]}')
148
+ if not select.select([master], [], [], .03)[0]:
149
+ continue
150
+ chunk = os.read(master, 65536)
151
+ transcript.extend(chunk)
152
+ screen.feed(chunk)
153
+ if b'\x1b[6n' in chunk:
154
+ os.write(master, b'\x1b[1;1R')
155
+ if b'\x1b[c' in chunk:
156
+ os.write(master, b'\x1b[?1;2c')
157
+
158
+ def until(predicate, label, timeout=10):
159
+ deadline = time.monotonic() + timeout
160
+ while time.monotonic() < deadline:
161
+ pump(.1)
162
+ if predicate():
163
+ return
164
+ raise TimeoutError(f'timeout waiting for {label}: screen={screen.visible()[-1200:]!r} '
165
+ f'transcript={bytes(transcript[-3000:])!r}')
166
+
167
+ until(lambda: 'ask anything' in screen.visible().lower() or
168
+ 'what is the tech' in screen.visible().lower(), 'home composer', timeout=args.timeout)
169
+ # Open a native session first so the footer/model state is explicit.
170
+ os.write(master, b'\r')
171
+ until(lambda: 'Build · CTG Picker A' in screen.visible(), 'active session with configured model A')
172
+ # Native runtime binding: Ctrl+X leader then m => model.list.
173
+ configured_model_id = 'fixture'
174
+ if 'Build · CTG Picker A' not in screen.visible():
175
+ raise AssertionError('configured model A is not visible in active session metadata before picker')
176
+ os.write(master, b'\x18')
177
+ pump(.12)
178
+ os.write(master, b'm')
179
+ until(lambda: 'select model' in screen.visible().lower(), 'native model picker')
180
+ requests_before_cancel = len(provider.received)
181
+
182
+ # Filter to the two controlled choices, prove keyboard highlight moved
183
+ # from A to B via ANSI inverse rendition at the model row, then Escape.
184
+ os.write(master, b'CTG Picker')
185
+ until(lambda: 'CTG Picker A' in screen.visible() and 'CTG Picker B' in screen.visible(),
186
+ 'both filtered model choices')
187
+ picker_before = screen.visible()
188
+ if '● CTG Picker A' not in picker_before:
189
+ raise AssertionError(f'current model A not visibly selected before navigation: {picker_before[-1200:]}')
190
+ raw_before_navigation = bytes(transcript)
191
+ style_a_before = option_style_bytes(raw_before_navigation, 'CTG Picker A')
192
+ if not style_a_before:
193
+ raise AssertionError('could not capture the currently selected A option style before navigation')
194
+ os.write(master, b'\x1b[B')
195
+ pump(.25)
196
+ picker_after_down = screen.visible()
197
+ if 'CTG Picker B' not in picker_after_down or 'Select model' not in picker_after_down:
198
+ raise AssertionError(f'model picker did not remain with alternate option after ArrowDown: {picker_after_down[-1200:]}')
199
+ style_a = option_style_bytes(bytes(transcript), 'CTG Picker A')
200
+ style_b = option_style_bytes(bytes(transcript), 'CTG Picker B')
201
+ if (not style_a or not style_b or style_b != style_a_before or
202
+ style_a == style_a_before or style_a == style_b):
203
+ raise AssertionError('ArrowDown did not move the prior selected styling from A to B: '
204
+ f'before selected A={style_a_before!r}; after A={style_a!r}, B={style_b!r}')
205
+ # In this dialog Escape first clears the active model-search filter;
206
+ # a second Escape closes the picker without submitting the highlight.
207
+ os.write(master, b'\x1b')
208
+ pump(.35)
209
+ if 'Select model' in screen.visible() and 'CTG Picker' not in screen.visible():
210
+ raise AssertionError('unexpected intermediate model-search reset state')
211
+ os.write(master, b'\x1b')
212
+ until(lambda: 'select model' not in screen.visible().lower(), 'model picker Escape dismissal')
213
+ after_cancel = screen.visible()
214
+ requests_after_cancel = len(provider.received)
215
+ if 'ask anything' not in after_cancel.lower() and '┃' not in after_cancel:
216
+ raise AssertionError(f'composer not restored after model picker Escape: {after_cancel[-1000:]}')
217
+ if requests_after_cancel != requests_before_cancel:
218
+ raise AssertionError('provider request occurred during model-picker navigation/cancel')
219
+
220
+ rendered_model_label_after_cancel = 'Build · CTG Picker A CTG Picker Fixture' in after_cancel
221
+ if not rendered_model_label_after_cancel:
222
+ raise AssertionError(f'rendered active-session model label no longer shows A after cancel: {after_cancel[-1200:]}')
223
+
224
+ # Enter the session, then submit a new unique prompt from the session
225
+ # composer so model selection is evidenced by a real provider request.
226
+ os.write(master, b'MODEL-A-AFTER-CANCEL')
227
+ until(lambda: 'MODEL-A-AFTER-CANCEL' in screen.visible(), 'post-cancel prompt staged')
228
+ os.write(master, b'\r')
229
+ until(lambda: len(provider.received) > requests_before_cancel,
230
+ 'post-cancel fixture request', timeout=20)
231
+ after_models = [body.get('model') for body in provider.received[requests_before_cancel:]]
232
+ after_messages = provider.received[-1].get('messages', [])
233
+ exact_prompt = any(message.get('role') == 'user' and
234
+ 'MODEL-A-AFTER-CANCEL' in str(message.get('content', ''))
235
+ for message in after_messages)
236
+ if not exact_prompt and not any('MODEL-A-AFTER-CANCEL' in str(message) for message in after_messages):
237
+ pump(.8)
238
+ if len(provider.received) == requests_before_cancel:
239
+ os.write(master, b'\r')
240
+ until(lambda: len(provider.received) > requests_before_cancel,
241
+ 'second enter post-cancel fixture request')
242
+ after_models = [body.get('model') for body in provider.received[requests_before_cancel:]]
243
+ after_messages = provider.received[-1].get('messages', [])
244
+ exact_prompt = any(message.get('role') == 'user' and
245
+ 'MODEL-A-AFTER-CANCEL' in str(message.get('content', ''))
246
+ for message in after_messages)
247
+ if not exact_prompt:
248
+ raise AssertionError('post-cancel request did not contain the unique exact prompt')
249
+ if not after_models or any(model != 'fixture' for model in after_models):
250
+ raise AssertionError(f'configured model changed after cancellation: {after_models!r}')
251
+
252
+ result['checks']['modelPickerNavigationEscapeUnchangedModel'] = {
253
+ 'status': 'PASS',
254
+ 'modelPickerObserved': True,
255
+ 'modelOptionsVisible': ['CTG Picker A', 'CTG Picker B'],
256
+ 'currentModelBeforeNavigationVisible': 'CTG Picker A' in picker_before,
257
+ 'navigationKeySent': 'ArrowDown',
258
+ 'alternateOptionVisibleAfterNavigation': 'CTG Picker B' in picker_after_down,
259
+ 'selectedOptionStyleAfterNavigation': style_b,
260
+ 'otherOptionStyleAfterNavigation': style_a,
261
+ 'escapeDismissedPicker': True,
262
+ 'composerRestored': True,
263
+ 'providerRequestsDuringCancel': requests_after_cancel - requests_before_cancel,
264
+ 'renderedActiveSessionModelLabelStillShowsA': rendered_model_label_after_cancel,
265
+ 'configuredModelIdBeforePicker': configured_model_id,
266
+ 'postCancelProviderModels': after_models,
267
+ 'postCancelUniquePromptAccepted': exact_prompt,
268
+ 'provider': 'loopback offline fixture',
269
+ }
270
+ result['status'] = 'PASS_BOUNDED'
271
+ result['checks']['physicalOSClipboardVerified'] = {'status': 'NOT_TESTED'}
272
+ pump(.3)
273
+ except Exception as error:
274
+ result['status'] = 'INCOMPLETE'
275
+ result['checks'].setdefault('modelPickerNavigationEscapeUnchangedModel', {
276
+ 'status': 'FAIL', 'error': f'{type(error).__name__}: {error}'})
277
+ finally:
278
+ if child is not None and child.poll() is None:
279
+ os.write(master, b'\x15/quit\r')
280
+ try:
281
+ child.wait(timeout=3)
282
+ except subprocess.TimeoutExpired:
283
+ os.killpg(child.pid, signal.SIGTERM)
284
+ try:
285
+ child.wait(timeout=2)
286
+ except subprocess.TimeoutExpired:
287
+ os.killpg(child.pid, signal.SIGKILL)
288
+ child.wait()
289
+ if slave is not None:
290
+ os.close(slave)
291
+ if master is not None:
292
+ os.close(master)
293
+ server.shutdown()
294
+ server.server_close()
295
+
296
+ rendered = json.dumps(result, ensure_ascii=False, indent=2) + '\n'
297
+ print(rendered, end='')
298
+ if args.artifact:
299
+ artifact = Path(args.artifact).resolve()
300
+ artifact.parent.mkdir(parents=True, exist_ok=True)
301
+ artifact.write_text(rendered, encoding='utf8')
302
+ return 0 if result['status'] == 'PASS_BOUNDED' else 2
303
+
304
+
305
+ if __name__ == '__main__':
306
+ raise SystemExit(main())