carthagent 1.0.7 → 1.1.0-preview.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +140 -83
- package/bin/ctg.mjs +14 -732
- package/docs/glyph-review/host-chrome-glyph-compatibility-v4.md +83 -0
- package/docs/glyph-review/test-isolation-v1.md +26 -0
- package/docs/glyph-review/typecheck-base.log +1 -0
- package/docs/glyph-review/typecheck-v4.log +1 -0
- package/docs/glyph-review/unicode-inventory-v4.csv +402 -0
- package/docs/help-dispatch-review.md +69 -0
- package/docs/legacy-cli.md +150 -0
- package/docs/live-theme-plugin-api.md +18 -0
- package/docs/native-cli.md +102 -0
- package/docs/native-compatibility.md +16 -0
- package/docs/native-daily-quests.md +132 -0
- package/docs/native-glyph-pty.md +59 -0
- package/docs/native-manual-testing.md +336 -0
- package/docs/native-slash-acceptance-handoff.md +49 -0
- package/docs/native-terminal-paired-failure-2026-10-17.md +40 -0
- package/docs/native-terminal-performance.md +310 -0
- package/docs/native-terminal-semantics-evidence/README.md +25 -0
- package/docs/native-terminal-semantics-evidence/controlled-host-model-selftest.json +16 -0
- package/docs/native-terminal-semantics-evidence/current-acceptance-status.json +59 -0
- package/docs/native-terminal-semantics-evidence/final-binary-focused-85bb44a.json +154 -0
- package/docs/native-terminal-semantics-evidence/final-binary-full-incomplete-85bb44a.json +160 -0
- package/docs/native-terminal-semantics-evidence/final-binary-layout-85bb44a.json +149 -0
- package/docs/native-terminal-semantics-evidence/final-binary-layout-interactive-85bb44a.json +159 -0
- package/docs/native-terminal-semantics-evidence/final-binary-model-picker-escape-unchanged-85bb44a.json +44 -0
- package/docs/native-terminal-semantics-evidence/final-binary-model-picker-exact-identity-85bb44a.json +47 -0
- package/docs/native-terminal-semantics-evidence/final-binary-model-picker-unchanged-model-incomplete-85bb44a.json +58 -0
- package/docs/native-terminal-semantics-evidence/final-binary-model-selftest.json +16 -0
- package/docs/native-terminal-semantics-evidence/final-binary-picker-cancel-incomplete-85bb44a.json +62 -0
- package/docs/native-terminal-semantics-evidence/focused-clipboard-title-stale-binary.json +161 -0
- package/docs/native-terminal-semantics-evidence/layout-stale-binary-observation-not-accepted.json +153 -0
- package/docs/native-terminal-semantics-evidence/picker-latest-incomplete-attempt.json +62 -0
- package/docs/native-terminal-semantics-evidence/picker-previous-probe-pass.json +61 -0
- package/docs/native-terminal-semantics.md +89 -0
- package/docs/native-upgrade.md +177 -0
- package/docs/opencode-composed-source-review.md +89 -0
- package/docs/opencode-migration.md +225 -9
- package/docs/opencode-owned-runtime.md +241 -0
- package/docs/opencode-runtime.md +12 -0
- package/docs/opencode-theme-provider-proof/README.md +55 -0
- package/docs/opencode-theme-provider-proof/live-theme-api-base.patch +122 -0
- package/docs/opencode-theme-provider-proof/plugin-theme-provider.patch +107 -0
- package/docs/opencode-theme-provider-proof/session-ui-base.patch +1217 -0
- package/docs/owned-host-controls-proof.md +39 -0
- package/docs/owned-runtime-release-manifest.md +42 -0
- package/docs/owned-visual-clear-proof.md +40 -0
- package/docs/patches/opencode-v2.0.32-glyph-spinner-test-isolation-v1.patch +50 -0
- package/docs/patches/opencode-v2.0.32-glyphs.patch +3670 -0
- package/docs/patches/opencode-v2.0.32-help-dispatch.patch +156 -0
- package/docs/patches/opencode-v2.0.32-live-theme-api.patch +122 -0
- package/docs/patches/opencode-v2.0.32-session-ui-combined-v1.patch +5272 -0
- package/docs/patches/opencode-v2.0.32-session-ui-combined-v2.patch +6052 -0
- package/docs/patches/opencode-v2.0.32-session-ui-combined-v3.patch +8657 -0
- package/docs/patches/opencode-v2.0.32-session-ui-combined-v4.patch +9928 -0
- package/docs/patches/opencode-v2.0.32-session-ui-combined-v5.patch +13965 -0
- package/docs/patches/opencode-v2.0.32-session-ui.patch +5137 -0
- package/docs/release-notes-native-draft.md +73 -0
- package/docs/release-notes-preview.10.md +121 -0
- package/docs/release-notes-preview.11.md +76 -0
- package/docs/release-notes-preview.12.md +55 -0
- package/docs/release-notes-preview.13.md +68 -0
- package/docs/release-notes-preview.9.md +130 -0
- package/docs/source-preview.md +120 -0
- package/evaluate.mjs +28 -1
- package/lib/budget.mjs +1 -1
- package/lib/codegraph.mjs +1 -1
- package/lib/delivery-review.mjs +11 -1
- package/lib/delivery.mjs +3 -2
- package/lib/environment.mjs +8 -0
- package/lib/guidance.mjs +1 -1
- package/lib/opencode/cli-runtime.mjs +1171 -0
- package/lib/opencode/cli.mjs +252 -0
- package/lib/opencode/credentials.mjs +63 -8
- package/lib/opencode/demo.mjs +53 -0
- package/lib/opencode/evaluation-budget-plugin/index.ts +3 -0
- package/lib/opencode/evaluation.mjs +32 -12
- package/lib/opencode/headless.mjs +8 -4
- package/lib/opencode/provider-policy.mjs +23 -0
- package/lib/opencode/review.mjs +37 -16
- package/lib/opencode/runtime-artifact.mjs +335 -0
- package/lib/opencode/runtime-github-transport.mjs +121 -0
- package/lib/opencode/runtime-release.json +36 -0
- package/lib/opencode/runtime.mjs +299 -20
- package/lib/opencode/service.mjs +77 -35
- package/lib/opencode/session-options.mjs +6 -2
- package/lib/opencode/update.mjs +222 -0
- package/lib/opencode/workspace.mjs +1 -1
- package/lib/planning-advice.mjs +2 -2
- package/lib/providers/experiential.mjs +2 -2
- package/lib/tui/framing.mjs +1 -80
- package/lib/update.mjs +10 -1
- package/package.json +33 -7
- package/plugins/carthagent/README.md +23 -0
- package/plugins/carthagent/src/bootstrap.ts +1 -1
- package/plugins/carthagent/src/daily-evidence.mjs +57 -0
- package/plugins/carthagent/src/daily-quests-rpc.ts +50 -0
- package/plugins/carthagent/src/daily-source-rpc.ts +52 -0
- package/plugins/carthagent/src/index.ts +71 -24
- package/plugins/carthagent/src/rpc.ts +51 -6
- package/plugins/carthagent/src/server/codegraph.ts +12 -5
- package/plugins/carthagent/src/server/daily-quests-rpc.ts +277 -0
- package/plugins/carthagent/src/server/daily-source-rpc.ts +211 -0
- package/plugins/carthagent/src/server/delivery.ts +227 -112
- package/plugins/carthagent/src/server/framing.mjs +80 -0
- package/plugins/carthagent/src/server/policy.ts +1 -1
- package/plugins/carthagent/src/server/providers.ts +59 -31
- package/plugins/carthagent/src/server/session-controls.ts +3 -1
- package/plugins/carthagent/src/server/ship-agents.ts +77 -0
- package/plugins/carthagent/src/server/ship-context.mjs +60 -0
- package/plugins/carthagent/src/server/ship-guidance.ts +41 -0
- package/plugins/carthagent/src/server/ship-rpc.ts +69 -0
- package/plugins/carthagent/src/server/voyages-rpc.ts +223 -0
- package/plugins/carthagent/src/testing.ts +5 -0
- package/plugins/carthagent/src/tui.tsx +496 -0
- package/plugins/carthagent/src/ui/client.ts +125 -0
- package/plugins/carthagent/src/ui/commands.ts +70 -0
- package/plugins/carthagent/src/ui/daily-editor-focus.mjs +52 -0
- package/plugins/carthagent/src/ui/daily-editor.mjs +4 -0
- package/plugins/carthagent/src/ui/daily-quests-events.mjs +18 -0
- package/plugins/carthagent/src/ui/daily-quests-state.mjs +218 -0
- package/plugins/carthagent/src/ui/daily-quests.tsx +235 -0
- package/plugins/carthagent/src/ui/host-state.ts +42 -0
- package/plugins/carthagent/src/ui/model-command.ts +32 -0
- package/plugins/carthagent/src/ui/native-navigation.ts +23 -0
- package/plugins/carthagent/src/ui/native-review.d.mts +33 -0
- package/plugins/carthagent/src/ui/native-review.mjs +159 -0
- package/plugins/carthagent/src/ui/resume-session.mjs +34 -0
- package/plugins/carthagent/src/ui/select-mouse.ts +17 -0
- package/plugins/carthagent/src/ui/session-preferences.d.mts +7 -0
- package/plugins/carthagent/src/ui/session-preferences.mjs +14 -0
- package/plugins/carthagent/src/ui/theme-provision.d.mts +3 -0
- package/plugins/carthagent/src/ui/theme-provision.mjs +134 -0
- package/plugins/carthagent/src/ui/voyages-navigation-state.mjs +56 -0
- package/plugins/carthagent/src/ui/voyages-navigation.tsx +131 -0
- package/plugins/carthagent/src/ui/voyages-state.mjs +142 -0
- package/plugins/carthagent/src/ui/voyages.tsx +129 -0
- package/plugins/carthagent/src/ui/workspace-live.ts +61 -0
- package/plugins/carthagent/src/ui/workspace.tsx +379 -0
- package/plugins/carthagent/src/voyages-rpc.ts +60 -0
- package/plugins/carthagent/src/workspace-rpc.ts +36 -0
- package/plugins/carthagent/tests/commands.test.ts +107 -0
- package/plugins/carthagent/tests/session-preferences.test.mjs +41 -0
- package/plugins/carthagent/tests/voyages-navigation.test.tsx +269 -0
- package/plugins/carthagent/themes/carthage.json +67 -0
- package/plugins/carthagent/tui.ts +26 -1
- package/script/ctg/build-owned-runtime.sh +166 -0
- package/tests/budget-engine.test.mjs +4 -3
- package/tests/cli.test.mjs +52 -96
- package/tests/daily-evidence.test.mjs +62 -0
- package/tests/delivery-review.test.mjs +11 -0
- package/tests/delivery.test.mjs +22 -1
- package/tests/fixtures/native-discovery-plugin/server.ts +15 -0
- package/tests/generate-owned-runtime-release.test.mjs +354 -0
- package/tests/installed-runtime-contract.test.mjs +43 -0
- package/tests/native-cli-host-smoke.mjs +457 -0
- package/tests/native-daily-editor-focus.test.mjs +213 -0
- package/tests/native-daily-editor.test.mjs +43 -0
- package/tests/native-daily-quests-layout.test.mjs +53 -0
- package/tests/native-daily-quests-rpc.test.mjs +456 -0
- package/tests/native-daily-quests-ui.test.mjs +778 -0
- package/tests/native-daily-source.test.mjs +451 -0
- package/tests/native-demo-ui.test.mjs +48 -0
- package/tests/native-evaluation-admission.test.mjs +24 -0
- package/tests/native-fixture-browser-cleanup.test.mjs +65 -0
- package/tests/native-fixture-runtime.test.mjs +111 -0
- package/tests/native-fixture-stop-diagnostics.test.mjs +67 -0
- package/tests/native-glyph-pty.test.mjs +107 -0
- package/tests/native-manual-projects.test.mjs +170 -0
- package/tests/native-model-command.test.mjs +67 -0
- package/tests/native-owned-runtime-env.test.mjs +140 -0
- package/tests/native-paired-guards.test.mjs +464 -0
- package/tests/native-provider-policy.test.mjs +31 -0
- package/tests/native-pty-invocation.test.mjs +114 -0
- package/tests/native-pty-runtime.test.mjs +45 -0
- package/tests/native-resume-session.test.mjs +46 -0
- package/tests/native-review-consumer.test.mjs +286 -0
- package/tests/native-runtime-identity.test.mjs +214 -0
- package/tests/native-select-mouse.test.mjs +23 -0
- package/tests/native-ship-parity.test.mjs +526 -0
- package/tests/native-ship-plan.test.mjs +85 -0
- package/tests/native-ship-rpc.test.mjs +71 -0
- package/tests/native-ui-commands.test.mjs +6 -0
- package/tests/native-voyages-layout.test.mjs +28 -0
- package/tests/native-voyages-navigation.test.mjs +169 -0
- package/tests/native-voyages-rpc.test.mjs +395 -0
- package/tests/native-voyages-ui.test.mjs +277 -0
- package/tests/opencode-atomic-write.test.mjs +55 -0
- package/tests/opencode-cli.test.mjs +1312 -0
- package/tests/opencode-codegraph-plugin.test.mjs +47 -0
- package/tests/opencode-delivery.test.mjs +16 -0
- package/tests/opencode-providers.test.mjs +245 -18
- package/tests/opencode-runtime.test.mjs +546 -8
- package/tests/opencode-session-controls.test.mjs +15 -0
- package/tests/opencode-ship-rpc.test.mjs +71 -0
- package/tests/opencode-update.test.mjs +475 -0
- package/tests/opencode-workspace.test.mjs +51 -2
- package/tests/owned-runtime-publish-workflow.test.mjs +477 -0
- package/tests/release-workflow.test.mjs +46 -0
- package/tests/runtime-artifact.test.mjs +269 -0
- package/tests/runtime-development-trust.test.mjs +230 -0
- package/tests/runtime-github-transport.test.mjs +132 -0
- package/tests/runtime-token-env.test.mjs +23 -0
- package/tests/server.test.mjs +3 -1
- package/tests/ship-telemetry.test.mjs +1 -1
- package/tests/source-preview.test.mjs +253 -0
- package/tests/test_native_terminal_probe.py +76 -0
- package/tests/test_native_terminal_semantics.py +232 -0
- package/tests/update.test.mjs +42 -5
- package/tests/verify-draft-runtime-gate.test.mjs +280 -0
- package/tests/verify-owned-runtime-release.test.mjs +371 -0
- package/tests/workspace-live.test.mjs +127 -0
- package/tools/benchmark/diagnose-request.mjs +59 -36
- package/tools/benchmark/eval-common.mjs +3 -3
- package/tools/benchmark/evaluators.test.mjs +4 -4
- package/tools/benchmark/guard.test.mjs +35 -2
- package/tools/benchmark/guard.ts +20 -7
- package/tools/benchmark/planning-startup.test.mjs +30 -34
- package/tools/benchmark/runner.mjs +12 -7
- package/tools/benchmark/runner.test.mjs +23 -7
- package/tools/benchmark/upstream-error.test.mjs +16 -2
- package/tools/generate-owned-runtime-release.mjs +217 -0
- package/tools/installed-runtime-contract.mjs +16 -0
- package/tools/manual-runtime-manifest.mjs +39 -0
- package/tools/native-fixture-browser-cleanup.mjs +59 -0
- package/tools/native-fixture-stop-diagnostics.mjs +43 -0
- package/tools/native-fixture.mjs +199 -40
- package/tools/native-glyph-helpers.py +127 -0
- package/tools/native-glyph-pty.py +144 -0
- package/tools/native-manual-projects.mjs +259 -0
- package/tools/native-manual.mjs +338 -0
- package/tools/native-model-picker-acceptance.py +306 -0
- package/tools/native-owned-runtime-env.sh +47 -0
- package/tools/native-paired-guards.mjs +282 -0
- package/tools/native-paired-manifest.mjs +72 -0
- package/tools/native-paired-orchestration.sh +51 -0
- package/tools/native-plugin-readiness.mjs +33 -0
- package/tools/native-plugin-readiness.test.mjs +53 -0
- package/tools/native-pty-invocation.mjs +26 -0
- package/tools/native-pty-runtime.mjs +83 -0
- package/tools/native-pty-smoke.py +147 -24
- package/tools/native-public-repo-shell-result.mjs +13 -0
- package/tools/native-public-repo-shell-result.test.mjs +28 -0
- package/tools/native-runtime-identity.mjs +201 -0
- package/tools/native-slash-acceptance-env.py +38 -0
- package/tools/native-terminal-semantics.py +876 -0
- package/tools/owned-shell-observer/index.ts +16 -0
- package/tools/owned-shell-observer/package.json +1 -0
- package/tools/owned-visual-clear-env.py +48 -0
- package/tools/owned-visual-clear-model.py +21 -0
- package/tools/pinned-owned-runtime-inputs.mjs +46 -0
- package/tools/prepare-native-manual.mjs +122 -0
- package/tools/run-native-terminal-paired.sh +403 -0
- package/tools/runtime-token-env.mjs +8 -0
- package/tools/seed-native-switch-fixture.mjs +79 -0
- package/tools/test-native-controls.mjs +3 -1
- package/tools/test-native-daily-quests.mjs +65 -0
- package/tools/test-native-default-cli.mjs +106 -0
- package/tools/test-native-delivery-host.mjs +102 -20
- package/tools/test-native-evaluation.mjs +7 -3
- package/tools/test-native-glyph-pty.mjs +94 -0
- package/tools/test-native-host.mjs +87 -63
- package/tools/test-native-outcomes.mjs +3 -1
- package/tools/test-native-package.mjs +206 -38
- package/tools/test-native-performance.mjs +26 -0
- package/tools/test-native-product.mjs +90 -0
- package/tools/test-native-pty.mjs +34 -9
- package/tools/test-native-public-repo.mjs +224 -0
- package/tools/test-native-refresh-reentry.mjs +106 -0
- package/tools/test-native-report.mjs +27 -20
- package/tools/test-native-review.mjs +75 -0
- package/tools/test-native-runtime.mjs +120 -0
- package/tools/test-native-ship-models.mjs +176 -0
- package/tools/test-native-ship-walkthrough.mjs +96 -0
- package/tools/test-native-slash-commands.mjs +125 -0
- package/tools/test-native-slash-commands.py +242 -0
- package/tools/test-native-swe-host.mjs +224 -55
- package/tools/test-native-tasks.mjs +77 -0
- package/tools/test-native-terminal-performance.py +887 -0
- package/tools/test-native-terminal-readiness.sh +85 -0
- package/tools/test-native-terminal-semantics-installed.mjs +38 -0
- package/tools/test-native-visual-clear-installed.mjs +48 -0
- package/tools/test-owned-host-controls.mjs +238 -0
- package/tools/test-owned-opencode-runtime.mjs +35 -0
- package/tools/test-owned-visual-clear-env.py +120 -0
- package/tools/test-owned-visual-clear.py +373 -0
- package/tools/verify-draft-runtime-gate.mjs +138 -0
- package/tools/verify-owned-runtime-release.mjs +114 -0
- package/tools/verify-release-version.mjs +21 -1
|
@@ -0,0 +1,876 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Real PTY terminal-semantics probe for the pinned native OpenCode host.
|
|
3
|
+
|
|
4
|
+
The fixture provider is loopback-only. This intentionally tests an actual TUI
|
|
5
|
+
input/submit round trip, not the JavaScript key parser in isolation.
|
|
6
|
+
"""
|
|
7
|
+
import argparse
|
|
8
|
+
import base64
|
|
9
|
+
import codecs
|
|
10
|
+
import fcntl
|
|
11
|
+
import errno
|
|
12
|
+
import hashlib
|
|
13
|
+
import http.server
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
import pty
|
|
18
|
+
import re
|
|
19
|
+
import select
|
|
20
|
+
import signal
|
|
21
|
+
import subprocess
|
|
22
|
+
import socketserver
|
|
23
|
+
import struct
|
|
24
|
+
import tempfile
|
|
25
|
+
import termios
|
|
26
|
+
import threading
|
|
27
|
+
import time
|
|
28
|
+
from datetime import datetime, timezone
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class Provider(http.server.BaseHTTPRequestHandler):
|
|
32
|
+
received = []
|
|
33
|
+
reply = 'FIXTURE_RESPONSE_OK'
|
|
34
|
+
|
|
35
|
+
def do_POST(self):
|
|
36
|
+
length = int(self.headers.get('content-length', '0'))
|
|
37
|
+
body = json.loads(self.rfile.read(length) or b'{}')
|
|
38
|
+
self.received.append(body)
|
|
39
|
+
def event(delta, finish_reason=None):
|
|
40
|
+
item = {'id': 'offline-fixture', 'object': 'chat.completion.chunk', 'created': 1,
|
|
41
|
+
'model': 'fixture', 'choices': [{'index': 0, 'delta': delta,
|
|
42
|
+
'finish_reason': finish_reason}]}
|
|
43
|
+
return b'data: ' + json.dumps(item).encode() + b'\n\n'
|
|
44
|
+
payload = (event({'role': 'assistant', 'content': self.reply}) +
|
|
45
|
+
event({}, 'stop') + b'data: [DONE]\n\n')
|
|
46
|
+
self.send_response(200)
|
|
47
|
+
self.send_header('content-type', 'text/event-stream')
|
|
48
|
+
self.send_header('cache-control', 'no-cache')
|
|
49
|
+
self.send_header('connection', 'close')
|
|
50
|
+
self.end_headers()
|
|
51
|
+
self.wfile.write(payload)
|
|
52
|
+
|
|
53
|
+
def log_message(self, *_args):
|
|
54
|
+
pass
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class Screen:
|
|
58
|
+
"""Small terminal host model for screen, title-stack, and OSC 52 state."""
|
|
59
|
+
ORIGINAL_TITLE = 'CTG-ORIGINAL-TITLE'
|
|
60
|
+
ORIGINAL_CLIPBOARD = 'CTG-SEEDED-CLIPBOARD'
|
|
61
|
+
|
|
62
|
+
def __init__(self, rows=32, columns=100, title=ORIGINAL_TITLE, clipboard=ORIGINAL_CLIPBOARD):
|
|
63
|
+
self.rows, self.columns = rows, columns
|
|
64
|
+
self.decoder = codecs.getincrementaldecoder('utf8')(errors='replace')
|
|
65
|
+
self.sync_open = False
|
|
66
|
+
self.queries = []
|
|
67
|
+
self.cells = [[' '] * columns for _ in range(rows)]
|
|
68
|
+
self.x = self.y = 0
|
|
69
|
+
self.mode = 'text'
|
|
70
|
+
self.csi = ''
|
|
71
|
+
self.csi_sequences = []
|
|
72
|
+
self.osc_escape = False
|
|
73
|
+
self.osc = ''
|
|
74
|
+
self.title = title
|
|
75
|
+
self.title_stack = []
|
|
76
|
+
self.title_history = []
|
|
77
|
+
self.title_controls = []
|
|
78
|
+
self.title_control_sequences = []
|
|
79
|
+
self.osc_title_updates = []
|
|
80
|
+
self.clipboard = clipboard
|
|
81
|
+
self.clipboard_history = []
|
|
82
|
+
self.clipboard_queries = []
|
|
83
|
+
self.rejected_clipboard_payloads = []
|
|
84
|
+
self.clipboard_readbacks = []
|
|
85
|
+
self.wrapCount = 0
|
|
86
|
+
|
|
87
|
+
def resize(self, rows, columns):
|
|
88
|
+
self.rows, self.columns = rows, columns
|
|
89
|
+
self.cells = [[' '] * columns for _ in range(rows)]
|
|
90
|
+
self.x = min(self.x, columns - 1)
|
|
91
|
+
self.y = min(self.y, rows - 1)
|
|
92
|
+
|
|
93
|
+
def feed(self, data):
|
|
94
|
+
text = self.decoder.decode(data)
|
|
95
|
+
for char in text:
|
|
96
|
+
if self.mode == 'osc':
|
|
97
|
+
if char == '\x07' or (self.osc_escape and char == '\\'):
|
|
98
|
+
self._osc(self.osc)
|
|
99
|
+
self.osc = ''
|
|
100
|
+
self.mode = 'text'
|
|
101
|
+
elif char != '\x1b':
|
|
102
|
+
self.osc += char
|
|
103
|
+
self.osc_escape = char == '\x1b'
|
|
104
|
+
continue
|
|
105
|
+
if self.mode == 'csi':
|
|
106
|
+
if '@' <= char <= '~':
|
|
107
|
+
if char == 't':
|
|
108
|
+
self.csi_sequences.append((self.csi, char))
|
|
109
|
+
self._csi(char, self.csi)
|
|
110
|
+
self.mode, self.csi = 'text', ''
|
|
111
|
+
else:
|
|
112
|
+
self.csi += char
|
|
113
|
+
continue
|
|
114
|
+
if self.mode == 'esc':
|
|
115
|
+
if char == '[':
|
|
116
|
+
self.mode, self.csi = 'csi', ''
|
|
117
|
+
elif char == ']':
|
|
118
|
+
self.mode = 'osc'
|
|
119
|
+
else:
|
|
120
|
+
self.mode = 'text'
|
|
121
|
+
continue
|
|
122
|
+
if char == '\x1b':
|
|
123
|
+
self.mode = 'esc'
|
|
124
|
+
elif char == '\r':
|
|
125
|
+
self.x = 0
|
|
126
|
+
elif char == '\n':
|
|
127
|
+
self.y = min(self.y + 1, self.rows - 1)
|
|
128
|
+
elif char == '\b':
|
|
129
|
+
self.x = max(0, self.x - 1)
|
|
130
|
+
elif char >= ' ':
|
|
131
|
+
if self.x >= self.columns:
|
|
132
|
+
self.wrapCount += 1
|
|
133
|
+
self.x, self.y = 0, min(self.y + 1, self.rows - 1)
|
|
134
|
+
self.cells[self.y][self.x] = char
|
|
135
|
+
self.x += 1
|
|
136
|
+
|
|
137
|
+
def _csi(self, final, params):
|
|
138
|
+
if params == '?2026' and final in ('h', 'l'):
|
|
139
|
+
self.sync_open = final == 'h'
|
|
140
|
+
if final == 'n' and params == '6':
|
|
141
|
+
self.queries.append(b'\x1b[1;1R')
|
|
142
|
+
if final == 'c' and params == '':
|
|
143
|
+
self.queries.append(b'\x1b[?1;2c')
|
|
144
|
+
values = [int(value) if value.isdigit() else 0 for value in params.lstrip('?').split(';')]
|
|
145
|
+
n = values[0] if values and values[0] else 1
|
|
146
|
+
if final in ('H', 'f'):
|
|
147
|
+
self.y = max(0, min(self.rows - 1, (values[0] if values and values[0] else 1) - 1))
|
|
148
|
+
self.x = max(0, min(self.columns - 1, (values[1] if len(values) > 1 and values[1] else 1) - 1))
|
|
149
|
+
elif final == 'A': self.y = max(0, self.y - n)
|
|
150
|
+
elif final == 'B': self.y = min(self.rows - 1, self.y + n)
|
|
151
|
+
elif final == 'C': self.x = min(self.columns - 1, self.x + n)
|
|
152
|
+
elif final == 'D': self.x = max(0, self.x - n)
|
|
153
|
+
elif final == 'K': self.cells[self.y][self.x:] = [' '] * (self.columns - self.x)
|
|
154
|
+
elif final == 'J' and values and values[0] == 2:
|
|
155
|
+
self.cells = [[' '] * self.columns for _ in range(self.rows)]
|
|
156
|
+
self.x = self.y = 0
|
|
157
|
+
elif final == 't' and values:
|
|
158
|
+
if values[0] == 22:
|
|
159
|
+
self.title_stack.append(self.title)
|
|
160
|
+
self.title_controls.append('save')
|
|
161
|
+
self.title_control_sequences.append(params)
|
|
162
|
+
elif values[0] == 23 and self.title_stack:
|
|
163
|
+
self.title = self.title_stack.pop()
|
|
164
|
+
self.title_history.append(self.title)
|
|
165
|
+
self.title_controls.append('restore')
|
|
166
|
+
self.title_control_sequences.append(params)
|
|
167
|
+
|
|
168
|
+
def _osc(self, value):
|
|
169
|
+
fields = value.split(';', 2)
|
|
170
|
+
if len(fields) >= 2 and fields[0] in ('0', '2'):
|
|
171
|
+
self.title = fields[-1]
|
|
172
|
+
self.title_history.append(self.title)
|
|
173
|
+
self.osc_title_updates.append({'kind': fields[0], 'title': self.title})
|
|
174
|
+
elif len(fields) == 3 and fields[0] == '52':
|
|
175
|
+
selection, encoded = fields[1], fields[2]
|
|
176
|
+
if selection != 'c':
|
|
177
|
+
self.rejected_clipboard_payloads.append({'reason': 'unsupported-selection', 'value': value})
|
|
178
|
+
return
|
|
179
|
+
if encoded == '?':
|
|
180
|
+
self.clipboard_queries.append(selection)
|
|
181
|
+
reply = f'\x1b]52;{selection};{base64.b64encode(self.clipboard.encode("utf8")).decode("ascii")}\x07'
|
|
182
|
+
self.clipboard_readbacks.append(reply)
|
|
183
|
+
return
|
|
184
|
+
try:
|
|
185
|
+
decoded = base64.b64decode(encoded, validate=True).decode('utf8')
|
|
186
|
+
except (ValueError, UnicodeDecodeError) as error:
|
|
187
|
+
self.rejected_clipboard_payloads.append({'reason': type(error).__name__, 'value': value})
|
|
188
|
+
return
|
|
189
|
+
self.clipboard = decoded
|
|
190
|
+
self.clipboard_history.append(decoded)
|
|
191
|
+
|
|
192
|
+
def query_clipboard(self):
|
|
193
|
+
before = len(self.clipboard_readbacks)
|
|
194
|
+
self.feed(b'\x1b]52;c;?\x07')
|
|
195
|
+
if len(self.clipboard_readbacks) != before + 1:
|
|
196
|
+
raise AssertionError('controlled host did not answer its OSC 52 clipboard query')
|
|
197
|
+
reply = self.clipboard_readbacks[-1]
|
|
198
|
+
fields = reply[2:-1].split(';', 2)
|
|
199
|
+
if len(fields) != 3 or fields[:2] != ['52', 'c']:
|
|
200
|
+
raise AssertionError('controlled host produced malformed OSC 52 clipboard query response')
|
|
201
|
+
try:
|
|
202
|
+
return base64.b64decode(fields[2], validate=True).decode('utf8')
|
|
203
|
+
except (ValueError, UnicodeDecodeError) as error:
|
|
204
|
+
raise AssertionError('controlled host produced invalid OSC 52 clipboard query payload') from error
|
|
205
|
+
|
|
206
|
+
def visible(self):
|
|
207
|
+
return '\n'.join(''.join(row).rstrip() for row in self.cells)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def controlled_terminal_model_fixture():
|
|
211
|
+
"""Self-test host state semantics; this is not physical OS clipboard proof."""
|
|
212
|
+
host = Screen()
|
|
213
|
+
host.feed(b'\x1b]52;c;a\x07') # invalid base64 padding
|
|
214
|
+
host.feed(b'\x1b]52;c;@@@\x07')
|
|
215
|
+
host.feed(b'\x1b]52;c;/w==\x07') # valid base64, invalid UTF-8
|
|
216
|
+
rejected_before_valid = len(host.rejected_clipboard_payloads)
|
|
217
|
+
if host.clipboard != Screen.ORIGINAL_CLIPBOARD or rejected_before_valid != 3:
|
|
218
|
+
raise AssertionError('controlled host accepted malformed OSC 52 or modified seeded clipboard')
|
|
219
|
+
|
|
220
|
+
expected_selection = 'SNIP'
|
|
221
|
+
expected_response = 'FINAL[SNIP]END'
|
|
222
|
+
for text in (expected_selection, expected_response):
|
|
223
|
+
sequence = f'\x1b]52;c;{base64.b64encode(text.encode()).decode()}\x07'
|
|
224
|
+
host.feed(sequence.encode())
|
|
225
|
+
if host.clipboard != text:
|
|
226
|
+
raise AssertionError('controlled host did not apply a valid OSC 52 clipboard write')
|
|
227
|
+
if host.query_clipboard() != text:
|
|
228
|
+
raise AssertionError('controlled host OSC 52 query did not read back the exact clipboard value')
|
|
229
|
+
if host.clipboard_queries != ['c', 'c']:
|
|
230
|
+
raise AssertionError('controlled host did not record both clipboard readback queries')
|
|
231
|
+
|
|
232
|
+
host.feed(b'\x1b[22;0t\x1b]0;TEMP-TUI-TITLE\x07\x1b]2;ANOTHER-TUI-TITLE\x07\x1b[23;0t')
|
|
233
|
+
if host.title != Screen.ORIGINAL_TITLE or host.title_history[-1] != Screen.ORIGINAL_TITLE:
|
|
234
|
+
raise AssertionError('controlled host did not restore the seeded title from its title stack')
|
|
235
|
+
return {
|
|
236
|
+
'status': 'MODEL_SELFTEST_PASS_NOT_NATIVE_ACCEPTANCE',
|
|
237
|
+
'scope': 'parser/state-model self-test only; does not launch OpenCode or exercise native PTY input',
|
|
238
|
+
'nativeAcceptance': 'NOT_RUN',
|
|
239
|
+
'physicalOSClipboardVerified': False,
|
|
240
|
+
'seedClipboardPreservedOnInvalidPayloads': True,
|
|
241
|
+
'invalidPayloadsRejected': rejected_before_valid,
|
|
242
|
+
'exactSelectionRoundTrip': expected_selection,
|
|
243
|
+
'exactLatestCopyRoundTrip': expected_response,
|
|
244
|
+
'clipboardQueries': len(host.clipboard_queries),
|
|
245
|
+
'titleRestoredFromStack': host.title,
|
|
246
|
+
'titleSaveRestoreOrder': host.title_controls,
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def sha256_file(path):
|
|
251
|
+
digest = hashlib.sha256()
|
|
252
|
+
with open(path, 'rb') as source:
|
|
253
|
+
for chunk in iter(lambda: source.read(1024 * 1024), b''):
|
|
254
|
+
digest.update(chunk)
|
|
255
|
+
return digest.hexdigest()
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def write_pty_input(master, data, deadline, on_output, child):
|
|
259
|
+
"""Deliver every byte while draining output; never block past the phase deadline.
|
|
260
|
+
|
|
261
|
+
PTYs have bounded queues in both directions. A blocking large paste can
|
|
262
|
+
deadlock against the host's paint writes, even inside a timed outer loop.
|
|
263
|
+
Restore descriptor flags on every exit so the normal read/drain paths retain
|
|
264
|
+
their existing EOF handling. The caller owns child termination and fd cleanup.
|
|
265
|
+
"""
|
|
266
|
+
flags = fcntl.fcntl(master, fcntl.F_GETFL)
|
|
267
|
+
pending = memoryview(data)
|
|
268
|
+
written = 0
|
|
269
|
+
try:
|
|
270
|
+
fcntl.fcntl(master, fcntl.F_SETFL, flags | os.O_NONBLOCK)
|
|
271
|
+
while written < len(pending):
|
|
272
|
+
remaining = deadline - time.monotonic()
|
|
273
|
+
if remaining <= 0:
|
|
274
|
+
raise TimeoutError(f'PTY input deadline exceeded: {written}/{len(pending)} bytes written; '
|
|
275
|
+
f'child status={child.poll()}')
|
|
276
|
+
if child.poll() is not None:
|
|
277
|
+
raise RuntimeError(f'native host exited during PTY input: {child.returncode}; '
|
|
278
|
+
f'{written}/{len(pending)} bytes written')
|
|
279
|
+
try:
|
|
280
|
+
readable, writable, _ = select.select([master], [master], [], min(.05, remaining))
|
|
281
|
+
if readable:
|
|
282
|
+
try:
|
|
283
|
+
chunk = os.read(master, 65536)
|
|
284
|
+
except BlockingIOError:
|
|
285
|
+
chunk = None
|
|
286
|
+
if chunk == b'':
|
|
287
|
+
raise RuntimeError('PTY reached EOF during input')
|
|
288
|
+
if chunk:
|
|
289
|
+
on_output(chunk)
|
|
290
|
+
if writable:
|
|
291
|
+
try:
|
|
292
|
+
count = os.write(master, pending[written:])
|
|
293
|
+
except BlockingIOError:
|
|
294
|
+
continue
|
|
295
|
+
if count == 0:
|
|
296
|
+
raise RuntimeError('PTY input write made no progress')
|
|
297
|
+
written += count
|
|
298
|
+
except InterruptedError:
|
|
299
|
+
continue
|
|
300
|
+
except OSError as error:
|
|
301
|
+
if error.errno == errno.EIO:
|
|
302
|
+
raise RuntimeError(f'PTY closed during input: {written}/{len(pending)} bytes written; '
|
|
303
|
+
f'child status={child.poll()}') from error
|
|
304
|
+
raise
|
|
305
|
+
finally:
|
|
306
|
+
fcntl.fcntl(master, fcntl.F_SETFL, flags)
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def drain_terminal_snapshot(master, screen, deadline, on_output, write_input, child):
|
|
310
|
+
"""Consume queued output and finish an active paint before observing state.
|
|
311
|
+
|
|
312
|
+
No idle sleep or new timeout: an incomplete synchronized paint/control/UTF-8
|
|
313
|
+
sequence must complete within the caller's existing input deadline. Queries
|
|
314
|
+
parsed from every read (including duplex input reads) are answered exactly
|
|
315
|
+
once; chunk boundaries are not terminal protocol boundaries.
|
|
316
|
+
"""
|
|
317
|
+
while True:
|
|
318
|
+
remaining = deadline - time.monotonic()
|
|
319
|
+
if remaining <= 0:
|
|
320
|
+
raise TimeoutError('coherent terminal snapshot exceeded existing input deadline')
|
|
321
|
+
if child.poll() is not None:
|
|
322
|
+
raise RuntimeError(f'native host exited during terminal snapshot: {child.returncode}')
|
|
323
|
+
if screen.queries:
|
|
324
|
+
write_input(screen.queries.pop(0))
|
|
325
|
+
continue
|
|
326
|
+
incomplete = screen.sync_open or screen.mode != 'text' or bool(screen.decoder.getstate()[0])
|
|
327
|
+
if not select.select([master], [], [], min(.05, remaining) if incomplete else 0)[0]:
|
|
328
|
+
if incomplete:
|
|
329
|
+
continue
|
|
330
|
+
return
|
|
331
|
+
chunk = os.read(master, 65536)
|
|
332
|
+
if not chunk:
|
|
333
|
+
raise RuntimeError('PTY reached EOF during terminal snapshot')
|
|
334
|
+
on_output(chunk)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def main():
|
|
338
|
+
parser = argparse.ArgumentParser()
|
|
339
|
+
parser.add_argument('--binary', default=os.environ.get('CTG_OPENCODE_BIN'))
|
|
340
|
+
parser.add_argument('--timeout', type=int, default=75)
|
|
341
|
+
parser.add_argument('--host-model-only', action='store_true',
|
|
342
|
+
help='run controlled terminal-host fixture without launching OpenCode')
|
|
343
|
+
parser.add_argument('--focused-host-roundtrip', action='store_true',
|
|
344
|
+
help='test short distinct selection/copy payloads and title restoration only')
|
|
345
|
+
parser.add_argument('--assertion-regression-only', action='store_true',
|
|
346
|
+
help='test picker return state and rendered narrow layout without large paste')
|
|
347
|
+
parser.add_argument('--picker-regression-only', action='store_true',
|
|
348
|
+
help='test Escape dismissal and restored home composer without sending a prompt')
|
|
349
|
+
parser.add_argument('--artifact', help='write the JSON result to this path as well as stdout')
|
|
350
|
+
args = parser.parse_args()
|
|
351
|
+
if args.host_model_only:
|
|
352
|
+
model_result = controlled_terminal_model_fixture()
|
|
353
|
+
rendered = json.dumps(model_result, ensure_ascii=False, indent=2) + '\n'
|
|
354
|
+
print(rendered, end='')
|
|
355
|
+
if args.artifact:
|
|
356
|
+
artifact_path = Path(args.artifact).resolve()
|
|
357
|
+
artifact_path.parent.mkdir(parents=True, exist_ok=True)
|
|
358
|
+
artifact_path.write_text(rendered, encoding='utf8')
|
|
359
|
+
return 0
|
|
360
|
+
if not args.binary:
|
|
361
|
+
parser.error('pass --binary or set CTG_OPENCODE_BIN to an owned native executable')
|
|
362
|
+
binary = os.path.abspath(args.binary)
|
|
363
|
+
if not os.path.isfile(binary):
|
|
364
|
+
parser.error(f'native binary does not exist: {binary}')
|
|
365
|
+
binary = os.path.realpath(binary)
|
|
366
|
+
binary_version = subprocess.run([binary, '--version'], capture_output=True, text=True, timeout=20)
|
|
367
|
+
if binary_version.returncode != 0 or '2.0.32' not in (binary_version.stdout + binary_version.stderr):
|
|
368
|
+
parser.error(f'owned executable must identify the OpenCode 2.0.32 line; got {binary_version.stdout.strip()!r}')
|
|
369
|
+
with open(binary, 'rb') as executable:
|
|
370
|
+
if executable.read(256).startswith(b'echo "Error: @opencode/cli'):
|
|
371
|
+
parser.error('native binary is the package postinstall placeholder, not an owned executable')
|
|
372
|
+
|
|
373
|
+
server = socketserver.ThreadingTCPServer(('127.0.0.1', 0), Provider)
|
|
374
|
+
threading.Thread(target=server.serve_forever, daemon=True).start()
|
|
375
|
+
port = server.server_address[1]
|
|
376
|
+
repo_root = Path(__file__).resolve().parents[1]
|
|
377
|
+
source_status = subprocess.run(['git', '-C', str(repo_root), 'status', '--porcelain', '--untracked-files=no'],
|
|
378
|
+
capture_output=True, text=True).stdout.strip()
|
|
379
|
+
source_commit = subprocess.run(['git', '-C', str(repo_root), 'rev-parse', 'HEAD'],
|
|
380
|
+
capture_output=True, text=True).stdout.strip()
|
|
381
|
+
binary_sha = sha256_file(binary)
|
|
382
|
+
result = {'startedAtUtc': datetime.now(timezone.utc).isoformat(),
|
|
383
|
+
'case': ('focused-host-roundtrip' if args.focused_host_roundtrip else
|
|
384
|
+
'layout-regression' if args.assertion_regression_only else
|
|
385
|
+
'picker-regression' if args.picker_regression_only else 'full-native-probe'),
|
|
386
|
+
'binary': binary, 'binaryResolvedPath': os.path.realpath(binary),
|
|
387
|
+
'binarySha256': binary_sha, 'buildHash': binary_sha,
|
|
388
|
+
'binaryVersion': (binary_version.stdout + binary_version.stderr).strip(),
|
|
389
|
+
'probeSha256': sha256_file(__file__),
|
|
390
|
+
'pluginSha256': sha256_file(repo_root / 'plugins' / 'carthagent' / 'src' / 'tui.tsx'),
|
|
391
|
+
'sourceCommit': source_commit,
|
|
392
|
+
'workingTreeDirty': bool(source_status),
|
|
393
|
+
'sourceStatus': source_status,
|
|
394
|
+
'sourceTreeSha256': hashlib.sha256(subprocess.run(
|
|
395
|
+
['git', '-C', str(repo_root), 'diff', 'HEAD'], capture_output=True).stdout).hexdigest(),
|
|
396
|
+
'sourceDiffDirty': subprocess.run(['git', '-C', str(repo_root), 'diff', '--quiet'],
|
|
397
|
+
capture_output=True).returncode != 0,
|
|
398
|
+
'provider': 'loopback offline fixture',
|
|
399
|
+
'terminal': {'initialRows': 32, 'initialColumns': 100,
|
|
400
|
+
'resizeRows': None if args.focused_host_roundtrip or args.picker_regression_only else 12,
|
|
401
|
+
'resizeColumns': None if args.focused_host_roundtrip or args.picker_regression_only else 40,
|
|
402
|
+
'TERM': 'xterm-256color'},
|
|
403
|
+
'controlledHostClipboardModel': True,
|
|
404
|
+
'physicalOSClipboardVerified': False,
|
|
405
|
+
'checks': {}}
|
|
406
|
+
with tempfile.TemporaryDirectory(prefix='ctg-terminal-semantics-') as temp:
|
|
407
|
+
root = os.path.abspath(temp)
|
|
408
|
+
project = os.path.join(root, 'project')
|
|
409
|
+
config = os.path.join(root, 'config', 'opencode')
|
|
410
|
+
for path in [project, config, os.path.join(root, 'home'), os.path.join(root, 'data'),
|
|
411
|
+
os.path.join(root, 'state'), os.path.join(root, 'cache')]:
|
|
412
|
+
os.makedirs(path, exist_ok=True)
|
|
413
|
+
with open(os.path.join(config, 'opencode.json'), 'w', encoding='utf8') as handle:
|
|
414
|
+
json.dump({'update': 'disable', 'model': 'ctgfixture/fixture',
|
|
415
|
+
'providers': {'ctgfixture': {'name': 'Offline Fixture',
|
|
416
|
+
'package': '@opencode/ai/providers/openai-compatible',
|
|
417
|
+
'settings': {'baseURL': f'http://127.0.0.1:{port}/v1'},
|
|
418
|
+
'models': {'fixture': {'name': 'Offline fixture', 'limit': {
|
|
419
|
+
'context': 32000, 'output': 1024}}}}}}, handle)
|
|
420
|
+
env = {key: os.environ[key] for key in ('PATH', 'LANG', 'LC_ALL') if key in os.environ}
|
|
421
|
+
env.update({'HOME': os.path.join(root, 'home'), 'TERM': 'xterm-256color',
|
|
422
|
+
'COLORTERM': 'truecolor', 'XDG_CONFIG_HOME': os.path.join(root, 'config'),
|
|
423
|
+
'XDG_DATA_HOME': os.path.join(root, 'data'), 'XDG_STATE_HOME': os.path.join(root, 'state'),
|
|
424
|
+
'XDG_CACHE_HOME': os.path.join(root, 'cache'), 'OPENCODE_DB': os.path.join(root, 'db.sqlite'),
|
|
425
|
+
'OPENCODE_CONFIG_DIR': config, 'OPENCODE_CONFIG': os.path.join(config, 'opencode.json'),
|
|
426
|
+
'OPENCODE_CONFIG_PROJECT_DISABLE': 'true'})
|
|
427
|
+
master, slave = pty.openpty()
|
|
428
|
+
fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack('HHHH', 32, 100, 0, 0))
|
|
429
|
+
child = subprocess.Popen([binary, '--standalone'], cwd=project, env=env,
|
|
430
|
+
stdin=slave, stdout=slave, stderr=slave, start_new_session=True)
|
|
431
|
+
os.close(slave)
|
|
432
|
+
output = bytearray()
|
|
433
|
+
screen = Screen()
|
|
434
|
+
initial_title = screen.title
|
|
435
|
+
errors = bytearray()
|
|
436
|
+
start = time.monotonic()
|
|
437
|
+
input_deadline = start + args.timeout
|
|
438
|
+
|
|
439
|
+
def capture_output(chunk):
|
|
440
|
+
output.extend(chunk)
|
|
441
|
+
screen.feed(chunk)
|
|
442
|
+
|
|
443
|
+
def write_input(data):
|
|
444
|
+
write_pty_input(master, data, input_deadline, capture_output, child)
|
|
445
|
+
|
|
446
|
+
sent = False
|
|
447
|
+
resized = False
|
|
448
|
+
picker_started = False
|
|
449
|
+
picker_cancelled = False
|
|
450
|
+
picker_home_before = False
|
|
451
|
+
picker_home_restored = False
|
|
452
|
+
copy_invoked = False
|
|
453
|
+
copy_confirmed = False
|
|
454
|
+
selection_sent = False
|
|
455
|
+
selection_confirmed = False
|
|
456
|
+
selected_clipboard_count = 0
|
|
457
|
+
copy_clipboard_count = 0
|
|
458
|
+
auth_dismissed = False
|
|
459
|
+
# Text is intentionally multi-line, Unicode, and large enough to expose
|
|
460
|
+
# truncation/byte-boundary bugs. It is sent only as bracketed paste.
|
|
461
|
+
regression_case = args.assertion_regression_only
|
|
462
|
+
picker_only = args.picker_regression_only
|
|
463
|
+
layout_only = regression_case and not picker_only
|
|
464
|
+
focused_case = args.focused_host_roundtrip or regression_case or picker_only
|
|
465
|
+
resize_case = layout_only or (not focused_case and not args.picker_regression_only)
|
|
466
|
+
exact = ('focused-host-request' if focused_case else
|
|
467
|
+
'native-pty: café 日本語 😀\n' + 'large-paste-λ-' * 1200 + '\nEND-PASTE-終')
|
|
468
|
+
fixture_response = 'FINAL[SNIP]END'
|
|
469
|
+
selection_target = 'SNIP'
|
|
470
|
+
payload = exact.encode('utf8')
|
|
471
|
+
Provider.received.clear()
|
|
472
|
+
Provider.reply = fixture_response
|
|
473
|
+
try:
|
|
474
|
+
while time.monotonic() - start < args.timeout:
|
|
475
|
+
if child.poll() is not None:
|
|
476
|
+
raise RuntimeError(f'native host exited early: {child.returncode}; {bytes(output[-3000:])!r}')
|
|
477
|
+
if select.select([master], [], [], .05)[0]:
|
|
478
|
+
try:
|
|
479
|
+
chunk = os.read(master, 65536)
|
|
480
|
+
except OSError as error:
|
|
481
|
+
if error.errno == errno.EIO and child.poll() is not None:
|
|
482
|
+
raise RuntimeError(f'native host exited {child.returncode}; '
|
|
483
|
+
f'transcript={bytes(output[-5000:])!r}') from error
|
|
484
|
+
raise
|
|
485
|
+
capture_output(chunk)
|
|
486
|
+
drain_terminal_snapshot(master, screen, input_deadline, capture_output, write_input, child)
|
|
487
|
+
elapsed = time.monotonic() - start
|
|
488
|
+
visible = screen.visible().lower()
|
|
489
|
+
visible_original = screen.visible()
|
|
490
|
+
if 'connection lost' in visible or 'reconnecting to the server' in visible:
|
|
491
|
+
raise RuntimeError(f'native host reconnect overlay during terminal acceptance: {visible_original!r}')
|
|
492
|
+
home_ready = 'shift+tab agents' in visible and 'ctrl+p commands' in visible
|
|
493
|
+
onboarding_visible = ('connect opencode go' in visible or
|
|
494
|
+
'connect an integration to' in visible or
|
|
495
|
+
'send prompts' in visible)
|
|
496
|
+
if focused_case and not auth_dismissed and onboarding_visible:
|
|
497
|
+
write_input(b'\x1b')
|
|
498
|
+
auth_dismissed = True
|
|
499
|
+
auth_dismissed_at = time.monotonic()
|
|
500
|
+
if focused_case and auth_dismissed and time.monotonic() - auth_dismissed_at > .4:
|
|
501
|
+
visible = screen.visible().lower()
|
|
502
|
+
home_ready = 'shift+tab agents' in visible and 'ctrl+p commands' in visible
|
|
503
|
+
if not focused_case and not picker_started and elapsed > 2 and home_ready:
|
|
504
|
+
# Ctrl+O opens OpenCode's recent sessions/projects menu.
|
|
505
|
+
# Ctrl+P is command completion on this runtime and must not
|
|
506
|
+
# be mistaken for a session picker.
|
|
507
|
+
write_input(b'\x0f')
|
|
508
|
+
picker_started = True
|
|
509
|
+
picker_home_before = home_ready
|
|
510
|
+
picker_at = time.monotonic()
|
|
511
|
+
if picker_started and not picker_cancelled and (
|
|
512
|
+
'recent sessions' in visible or 'sessions and projects' in visible or
|
|
513
|
+
'select session' in visible):
|
|
514
|
+
write_input(b'\x1b')
|
|
515
|
+
picker_cancelled = True
|
|
516
|
+
picker_heading = next(heading for heading in
|
|
517
|
+
('recent sessions', 'sessions and projects', 'select session')
|
|
518
|
+
if heading in visible)
|
|
519
|
+
picker_cancelled_at = time.monotonic()
|
|
520
|
+
result['checks']['pickerEscapeCancel'] = {
|
|
521
|
+
'status': 'PENDING', 'pickerObserved': True,
|
|
522
|
+
'cancelKey': 'Escape', 'providerRequestsBeforePrompt': len(Provider.received)}
|
|
523
|
+
if picker_cancelled and result['checks'].get('pickerEscapeCancel', {}).get('status') == 'PENDING':
|
|
524
|
+
if time.monotonic() - picker_cancelled_at > .5:
|
|
525
|
+
still_open = picker_heading in screen.visible().lower()
|
|
526
|
+
picker_home_restored = ('shift+tab agents' in screen.visible().lower() and
|
|
527
|
+
'ctrl+p commands' in screen.visible().lower())
|
|
528
|
+
no_provider_request = len(Provider.received) == 0
|
|
529
|
+
result['checks']['pickerEscapeCancel'] = {
|
|
530
|
+
'status': 'PASS' if picker_home_before and picker_home_restored and not still_open and no_provider_request else 'FAIL',
|
|
531
|
+
'pickerObserved': True, 'cancelKey': 'Escape',
|
|
532
|
+
'dismissed': not still_open, 'composerRestored': picker_home_restored,
|
|
533
|
+
'providerRequestsBeforePrompt': len(Provider.received),
|
|
534
|
+
'noPromptProviderRequestBeforeAcceptance': no_provider_request}
|
|
535
|
+
if picker_only:
|
|
536
|
+
break
|
|
537
|
+
if picker_started and not picker_cancelled and time.monotonic() - picker_at > 3:
|
|
538
|
+
# Escape regardless, so a command completion/menu cannot
|
|
539
|
+
# linger into the subsequent prompt entry.
|
|
540
|
+
write_input(b'\x1b')
|
|
541
|
+
picker_cancelled = True
|
|
542
|
+
result['checks']['pickerEscapeCancel'] = {
|
|
543
|
+
'status': 'UNSUPPORTED',
|
|
544
|
+
'reason': 'native model picker did not expose a recognizable visible heading; '
|
|
545
|
+
'Escape was sent to dismiss any command menu',
|
|
546
|
+
'providerRequestsBeforePrompt': len(Provider.received)}
|
|
547
|
+
if not sent and not picker_only and (((not focused_case) and
|
|
548
|
+
picker_cancelled and
|
|
549
|
+
result['checks'].get('pickerEscapeCancel', {}).get('status') != 'PENDING' and
|
|
550
|
+
elapsed > 5 and home_ready) or
|
|
551
|
+
(args.focused_host_roundtrip and elapsed > 2 and home_ready) or
|
|
552
|
+
(layout_only and elapsed > 2 and home_ready)):
|
|
553
|
+
# Finish picker acceptance on the original home screen before
|
|
554
|
+
# resizing/pasting: its footer remains visible under the picker.
|
|
555
|
+
# Narrow terminal stress is an actual resize on the PTY.
|
|
556
|
+
if resize_case:
|
|
557
|
+
fcntl.ioctl(master, termios.TIOCSWINSZ, struct.pack('HHHH', 12, 40, 0, 0))
|
|
558
|
+
screen.resize(12, 40)
|
|
559
|
+
output_before_resize = len(output)
|
|
560
|
+
os.kill(child.pid, signal.SIGWINCH)
|
|
561
|
+
resized = True
|
|
562
|
+
time.sleep(.25)
|
|
563
|
+
write_input(b'\x1b[200~' + payload + b'\x1b[201~')
|
|
564
|
+
sent = True
|
|
565
|
+
pasted_at = time.monotonic()
|
|
566
|
+
if sent and time.monotonic() - pasted_at > 1.5 and not Provider.received:
|
|
567
|
+
# Home prompt's first Enter opens a session; submit staged
|
|
568
|
+
# prompt only after the session composer is visible.
|
|
569
|
+
write_input(b'\r')
|
|
570
|
+
first_enter_at = time.monotonic()
|
|
571
|
+
pasted_at = time.monotonic() + 1e6
|
|
572
|
+
if sent and not Provider.received and 'first_enter_at' in locals() and time.monotonic() - first_enter_at > .75:
|
|
573
|
+
write_input(b'\r')
|
|
574
|
+
first_enter_at = time.monotonic() + 1e6
|
|
575
|
+
if Provider.received and 'provider_received_at' not in locals():
|
|
576
|
+
provider_received_at = time.monotonic()
|
|
577
|
+
visible_lines = screen.visible().splitlines()
|
|
578
|
+
mouse_mode = (b'\x1b[?1000h' in output or b'\x1b[?1002h' in output or
|
|
579
|
+
b'\x1b[?1006h' in output)
|
|
580
|
+
# Native Markdown renders [SNIP] as SNIP in the response body;
|
|
581
|
+
# the literal bracketed fixture also appears in the session
|
|
582
|
+
# header, which must not be mistaken for selectable response.
|
|
583
|
+
response_rows = [index for index, line in enumerate(visible_lines, 1)
|
|
584
|
+
if ('FINAL' + selection_target + 'END' in line and
|
|
585
|
+
1 <= index <= screen.rows - 4)]
|
|
586
|
+
response_settled = ('provider_received_at' in locals() and
|
|
587
|
+
time.monotonic() - provider_received_at > 1.0)
|
|
588
|
+
if (not selection_sent and response_rows and mouse_mode and response_settled):
|
|
589
|
+
target_row = response_rows[-1]
|
|
590
|
+
target_line = visible_lines[target_row - 1]
|
|
591
|
+
# Derive terminal cells from the original VT paint stream:
|
|
592
|
+
# the simple display model strips ANSI/does not model wide cells.
|
|
593
|
+
# For the ASCII fixture response on a single row, find the last
|
|
594
|
+
# matching occurrence and use the rendered cell substring.
|
|
595
|
+
start_col = target_line.rfind(selection_target) + 1
|
|
596
|
+
end_col = start_col + len(selection_target) - 1
|
|
597
|
+
selected_text = target_line[start_col - 1:end_col]
|
|
598
|
+
selected_clipboard_count = len(screen.clipboard_history)
|
|
599
|
+
selection_coordinates = {'startColumn': start_col, 'endColumn': end_col,
|
|
600
|
+
'row': target_row, 'screenLine': target_line,
|
|
601
|
+
'coordinateBasis': '1-based displayed terminal cells'}
|
|
602
|
+
# Send a real SGR press, held-button drag, and release as three
|
|
603
|
+
# separate PTY events, allowing the TUI event loop to process each.
|
|
604
|
+
for event in (f'\x1b[<0;{start_col};{target_row}M',
|
|
605
|
+
f'\x1b[<32;{end_col};{target_row}M',
|
|
606
|
+
f'\x1b[<0;{end_col};{target_row}m'):
|
|
607
|
+
write_input(event.encode())
|
|
608
|
+
time.sleep(.12)
|
|
609
|
+
selection_sent = True
|
|
610
|
+
selection_sent_at = time.monotonic()
|
|
611
|
+
response_observed = fixture_response.encode() in output
|
|
612
|
+
selection_values_now = screen.clipboard_history[selected_clipboard_count:]
|
|
613
|
+
selection_observed = (selection_sent and selection_confirmed and
|
|
614
|
+
selection_values_now == [selected_text])
|
|
615
|
+
if selection_observed and 'selection_readback_at_selection' not in locals():
|
|
616
|
+
selection_readback_at_selection = screen.query_clipboard()
|
|
617
|
+
if (Provider.received and not copy_invoked and
|
|
618
|
+
(response_observed or (args.focused_host_roundtrip and response_settled)) and
|
|
619
|
+
selection_observed and
|
|
620
|
+
not args.assertion_regression_only):
|
|
621
|
+
copy_clipboard_count = len(screen.clipboard_history)
|
|
622
|
+
write_input(b'/copy\r')
|
|
623
|
+
copy_invoked = True
|
|
624
|
+
copy_sent_at = time.monotonic()
|
|
625
|
+
if selection_sent and not selection_confirmed and time.monotonic() - selection_sent_at > .3:
|
|
626
|
+
selection_confirmed = True
|
|
627
|
+
if copy_invoked and not copy_confirmed and time.monotonic() - copy_sent_at > .75:
|
|
628
|
+
copy_confirmed = True
|
|
629
|
+
if Provider.received and copy_confirmed and time.monotonic() - provider_received_at > 2:
|
|
630
|
+
break
|
|
631
|
+
if not Provider.received and not picker_only:
|
|
632
|
+
raise RuntimeError('loopback provider received no request; TUI may not have accepted prompt; '
|
|
633
|
+
f'pickerStarted={picker_started}, pickerCancelled={picker_cancelled}, '
|
|
634
|
+
f'visible={screen.visible()[-1500:]!r}, transcript={bytes(output[-4000:])!r}')
|
|
635
|
+
bodies = Provider.received
|
|
636
|
+
if picker_only:
|
|
637
|
+
result['checks']['pickerEscapeCancel'] = result['checks'].get('pickerEscapeCancel', {
|
|
638
|
+
'status': 'UNVERIFIED', 'reason': 'picker case did not complete observable Escape dismissal'})
|
|
639
|
+
result['checks']['promptSubmission'] = {
|
|
640
|
+
'status': 'PASS' if not bodies and result['checks']['pickerEscapeCancel']['status'] == 'PASS' else 'FAIL',
|
|
641
|
+
'providerRequests': len(bodies),
|
|
642
|
+
'noPromptSentDuringPickerAcceptance': not bodies}
|
|
643
|
+
result['checks']['narrowLayout'] = {'status': 'NOT_RUN', 'reason': 'picker-only case does not resize terminal'}
|
|
644
|
+
result['checks']['focusedPromptTransport'] = {'status': 'NOT_RUN', 'reason': 'picker-only case intentionally submits no prompt'}
|
|
645
|
+
result['checks']['terminalTitleRestoration'] = {'status': 'NOT_RUN', 'reason': 'picker-only case exits without title acceptance'}
|
|
646
|
+
result['checks']['selectionClipboard'] = {'status': 'NOT_RUN', 'reason': 'picker-only case does not exercise selection'}
|
|
647
|
+
result['checks']['clipboardOSC52'] = {'status': 'NOT_RUN', 'reason': 'picker-only case does not exercise /copy'}
|
|
648
|
+
incomplete = [name for name, check in result['checks'].items()
|
|
649
|
+
if check['status'] not in ('PASS', 'NOT_RUN')]
|
|
650
|
+
result['status'] = 'INCOMPLETE' if incomplete else 'PASS'
|
|
651
|
+
result['incompleteChecks'] = incomplete
|
|
652
|
+
rendered = json.dumps(result, ensure_ascii=False, indent=2) + '\n'
|
|
653
|
+
print(rendered, end='')
|
|
654
|
+
if args.artifact:
|
|
655
|
+
artifact_path = Path(args.artifact).resolve()
|
|
656
|
+
artifact_path.parent.mkdir(parents=True, exist_ok=True)
|
|
657
|
+
artifact_path.write_text(rendered, encoding='utf8')
|
|
658
|
+
return 2 if incomplete else 0
|
|
659
|
+
else:
|
|
660
|
+
messages = bodies[-1].get('messages', [])
|
|
661
|
+
received_text = '\n'.join(str(part.get('text', '')) for message in messages
|
|
662
|
+
for part in (message.get('content', []) if isinstance(message.get('content'), list)
|
|
663
|
+
else [{'text': message.get('content', '')}]))
|
|
664
|
+
if not picker_only and exact not in received_text:
|
|
665
|
+
raise AssertionError(f'provider prompt differs: expected {len(exact)} chars, '
|
|
666
|
+
f'got {len(received_text)} chars')
|
|
667
|
+
matching_bodies = []
|
|
668
|
+
for body in bodies:
|
|
669
|
+
request_messages = body.get('messages', [])
|
|
670
|
+
request_text = '\n'.join(str(part.get('text', '')) for message in request_messages
|
|
671
|
+
for part in (message.get('content', []) if isinstance(message.get('content'), list)
|
|
672
|
+
else [{'text': message.get('content', '')}]))
|
|
673
|
+
if any(message.get('role') == 'user' and exact in str(message.get('content', ''))
|
|
674
|
+
for message in request_messages):
|
|
675
|
+
matching_bodies.append(body)
|
|
676
|
+
if not picker_only and not matching_bodies:
|
|
677
|
+
details = [[(message.get('role'), len(str(message.get('content', ''))),
|
|
678
|
+
exact in str(message.get('content', '')))
|
|
679
|
+
for message in body.get('messages', [])] for body in bodies]
|
|
680
|
+
raise AssertionError(f'expected exact user prompt, provider received {len(matching_bodies)} '
|
|
681
|
+
f'matching requests (total fixture requests {len(bodies)}): {details}')
|
|
682
|
+
if picker_only:
|
|
683
|
+
pass
|
|
684
|
+
elif focused_case:
|
|
685
|
+
result['checks']['focusedPromptTransport'] = {
|
|
686
|
+
'status': 'PASS' if exact in received_text else 'FAIL',
|
|
687
|
+
'exactProviderUserMessage': exact in received_text,
|
|
688
|
+
'providerRequests': len(bodies)}
|
|
689
|
+
if args.focused_host_roundtrip:
|
|
690
|
+
result['checks']['pickerEscapeCancel'] = {'status': 'NOT_RUN', 'reason': 'focused terminal-host case intentionally skips picker scenario'}
|
|
691
|
+
result['checks']['narrowLayout'] = {'status': 'NOT_RUN', 'reason': 'focused terminal-host case intentionally skips resize scenario'}
|
|
692
|
+
else:
|
|
693
|
+
result['checks']['pickerEscapeCancel'] = result['checks'].get('pickerEscapeCancel', {
|
|
694
|
+
'status': 'NOT_RUN', 'reason': 'focused layout regression case does not open picker'})
|
|
695
|
+
visible_after_resize = screen.visible()
|
|
696
|
+
narrow_content = fixture_response in visible_after_resize and '┃' in visible_after_resize
|
|
697
|
+
picker_absent = not any(heading in visible_after_resize.lower()
|
|
698
|
+
for heading in ('select model', 'models', 'search models'))
|
|
699
|
+
result['checks']['narrowLayout'] = {
|
|
700
|
+
'status': 'PASS' if resized and screen.rows == 12 and screen.columns == 40 and
|
|
701
|
+
child.poll() is None and narrow_content and picker_absent and
|
|
702
|
+
all(len(line) <= screen.columns for line in visible_after_resize.splitlines()) else 'UNVERIFIED',
|
|
703
|
+
'rows': screen.rows, 'columns': screen.columns, 'resizeRequested': resized,
|
|
704
|
+
'hostAlive': child.poll() is None, 'fixtureResponseVisible': fixture_response in visible_after_resize,
|
|
705
|
+
'composerVisible': '┃' in visible_after_resize, 'pickerAbsent': picker_absent,
|
|
706
|
+
'visibleRowsWithinWidth': all(len(row) <= screen.columns for row in screen.cells),
|
|
707
|
+
'visibleScreenExcerpt': visible_after_resize[-600:]}
|
|
708
|
+
else:
|
|
709
|
+
result['checks']['unicodeAndLargeBracketedPaste'] = {
|
|
710
|
+
'status': 'PASS' if exact in received_text else 'FAIL', 'exactProviderText': exact in received_text,
|
|
711
|
+
'characters': len(exact), 'utf8Bytes': len(payload),
|
|
712
|
+
'matchingPromptRequests': len(matching_bodies), 'totalFixtureRequests': len(bodies)}
|
|
713
|
+
visible_after_resize = screen.visible()
|
|
714
|
+
narrow_content = fixture_response in visible_after_resize and '┃' in visible_after_resize
|
|
715
|
+
picker_absent = 'select model' not in visible_after_resize.lower()
|
|
716
|
+
result['checks']['narrowLayout'] = {
|
|
717
|
+
'status': 'PASS' if resized and screen.rows == 12 and screen.columns == 40 and
|
|
718
|
+
child.poll() is None and narrow_content and picker_absent and
|
|
719
|
+
all(len(line) <= screen.columns for line in visible_after_resize.splitlines()) else 'UNVERIFIED',
|
|
720
|
+
'rows': screen.rows, 'columns': screen.columns, 'resizeRequested': resized,
|
|
721
|
+
'hostAlive': child.poll() is None, 'fixtureResponseVisible': fixture_response in visible_after_resize,
|
|
722
|
+
'composerVisible': '┃' in visible_after_resize, 'pickerAbsent': picker_absent,
|
|
723
|
+
'visibleRowsWithinWidth': all(len(row) <= screen.columns for row in screen.cells),
|
|
724
|
+
'observedTerminalOutputBytes': len(output) - output_before_resize,
|
|
725
|
+
'visibleScreenExcerpt': visible_after_resize[-600:]}
|
|
726
|
+
if child.poll() is not None:
|
|
727
|
+
raise AssertionError('host exited during narrow-layout probe')
|
|
728
|
+
# Selection is exercised by an SGR mouse drag. Screen applies OSC
|
|
729
|
+
# clipboard writes as a controlled host model, not an OS clipboard.
|
|
730
|
+
mouse_mode = b'\x1b[?1000h' in output or b'\x1b[?1002h' in output or b'\x1b[?1006h' in output
|
|
731
|
+
result['checks'].setdefault('pickerEscapeCancel', {
|
|
732
|
+
'status': 'UNSUPPORTED',
|
|
733
|
+
'reason': 'native home screen did not become interactive; no picker was opened'})
|
|
734
|
+
# Ask the TUI to exit through its own quit command so screen cleanup
|
|
735
|
+
# and title restoration run before inspecting final output.
|
|
736
|
+
exit_deadline = time.monotonic() + 5
|
|
737
|
+
input_deadline = exit_deadline
|
|
738
|
+
if child.poll() is None:
|
|
739
|
+
# Clear any remaining command/editor text, then request a normal
|
|
740
|
+
# TUI exit. Do not use a signal as a success-path substitute.
|
|
741
|
+
write_input(b'\x15/quit\r')
|
|
742
|
+
second_enter_sent = False
|
|
743
|
+
ctrl_c_sent = False
|
|
744
|
+
exit_actions = ['ctrl-u+quit+enter']
|
|
745
|
+
while child.poll() is None and time.monotonic() < exit_deadline:
|
|
746
|
+
remaining = exit_deadline - time.monotonic()
|
|
747
|
+
if not second_enter_sent and remaining < 4.2:
|
|
748
|
+
write_input(b'\r')
|
|
749
|
+
second_enter_sent = True
|
|
750
|
+
exit_actions.append('enter')
|
|
751
|
+
elif not ctrl_c_sent and remaining < 3.3:
|
|
752
|
+
# Ctrl-C is a TUI key in raw mode and calls the normal cleanup path.
|
|
753
|
+
write_input(b'\x03')
|
|
754
|
+
ctrl_c_sent = True
|
|
755
|
+
exit_actions.append('ctrl-c')
|
|
756
|
+
if select.select([master], [], [], .05)[0]:
|
|
757
|
+
try: chunk = os.read(master, 65536)
|
|
758
|
+
except OSError as error:
|
|
759
|
+
if error.errno == errno.EIO: break
|
|
760
|
+
raise
|
|
761
|
+
output.extend(chunk)
|
|
762
|
+
screen.feed(chunk)
|
|
763
|
+
exit_request_sent = False
|
|
764
|
+
try:
|
|
765
|
+
child.wait(timeout=max(1, exit_deadline - time.monotonic()))
|
|
766
|
+
except subprocess.TimeoutExpired:
|
|
767
|
+
exit_request_sent = True
|
|
768
|
+
os.killpg(child.pid, signal.SIGTERM)
|
|
769
|
+
child.wait(timeout=2)
|
|
770
|
+
exit_code = child.returncode
|
|
771
|
+
# A PTY may still contain delayed teardown writes after child exit.
|
|
772
|
+
# Drain to EOF, bounded, before judging title restoration.
|
|
773
|
+
drain_deadline = time.monotonic() + 1.5
|
|
774
|
+
pty_eof = False
|
|
775
|
+
while time.monotonic() < drain_deadline and not pty_eof:
|
|
776
|
+
if not select.select([master], [], [], .05)[0]:
|
|
777
|
+
continue
|
|
778
|
+
try:
|
|
779
|
+
chunk = os.read(master, 65536)
|
|
780
|
+
except OSError as error:
|
|
781
|
+
if error.errno == errno.EIO:
|
|
782
|
+
pty_eof = True
|
|
783
|
+
break
|
|
784
|
+
raise
|
|
785
|
+
if not chunk:
|
|
786
|
+
pty_eof = True
|
|
787
|
+
break
|
|
788
|
+
output.extend(chunk)
|
|
789
|
+
screen.feed(chunk)
|
|
790
|
+
title_pushes = screen.title_controls.count('save')
|
|
791
|
+
title_pops = screen.title_controls.count('restore')
|
|
792
|
+
save_index = screen.title_controls.index('save') if 'save' in screen.title_controls else -1
|
|
793
|
+
restore_index = screen.title_controls.index('restore') if 'restore' in screen.title_controls else -1
|
|
794
|
+
title_round_trip = (title_pushes > 0 and title_pops > 0 and not screen.title_stack and
|
|
795
|
+
save_index >= 0 and restore_index > save_index and
|
|
796
|
+
screen.title == initial_title and initial_title in screen.title_history and
|
|
797
|
+
exit_code == 0 and not exit_request_sent and pty_eof)
|
|
798
|
+
if restore_index >= 0:
|
|
799
|
+
restored_title = screen.title_history[-1] if screen.title_history else None
|
|
800
|
+
title_round_trip = title_round_trip and restored_title == initial_title
|
|
801
|
+
result['checks']['terminalTitleRestoration'] = {
|
|
802
|
+
'status': 'PASS' if title_round_trip else 'FAIL',
|
|
803
|
+
'reason': ('Controlled terminal host restored the seeded original title after normal TUI exit' if title_round_trip else
|
|
804
|
+
'Normal exit completed, but captured terminal output did not restore the seeded title through an ordered VT title-stack round trip'),
|
|
805
|
+
'controlledHostModel': True,
|
|
806
|
+
'normalExitCode': exit_code,
|
|
807
|
+
'exitActions': exit_actions,
|
|
808
|
+
'forcedTerminationAfterExitRequestTimeout': exit_request_sent,
|
|
809
|
+
'ptyDrainedToEof': pty_eof,
|
|
810
|
+
'titleSaveControls': title_pushes,
|
|
811
|
+
'titleRestoreControls': title_pops,
|
|
812
|
+
'titleControlOrder': screen.title_controls,
|
|
813
|
+
'modeledTitleStackDepth': len(screen.title_stack),
|
|
814
|
+
'titleControlsObserved': screen.title_controls,
|
|
815
|
+
'titleControlSequences': screen.title_control_sequences,
|
|
816
|
+
'initialTitle': initial_title,
|
|
817
|
+
'finalTitle': screen.title,
|
|
818
|
+
'titleSequenceObserved': screen.title_history,
|
|
819
|
+
'oscTitleUpdates': screen.osc_title_updates}
|
|
820
|
+
selection_values = screen.clipboard_history[selected_clipboard_count:copy_clipboard_count if copy_invoked else None] if selection_sent else []
|
|
821
|
+
selection_readback = locals().get('selection_readback_at_selection')
|
|
822
|
+
selection_model_pass = (selection_sent and selected_text == selection_target and
|
|
823
|
+
selection_values == [selection_target] and
|
|
824
|
+
selection_readback == selection_target)
|
|
825
|
+
result['checks']['selectionClipboard'] = {
|
|
826
|
+
'status': 'PASS' if selection_model_pass else 'UNVERIFIED',
|
|
827
|
+
'reason': ('Controlled host model accepted and queried OSC 52 containing only the exact selected substring; this is not physical OS clipboard proof' if selection_sent and selection_values == [selected_text] and selection_readback == selected_text
|
|
828
|
+
else 'Native selection gesture did not yield an exact selection OSC 52 update and modeled readback'),
|
|
829
|
+
'mouseReporting': mouse_mode, 'mouseDragSent': selection_sent,
|
|
830
|
+
'fixtureResponseInCapturedPty': fixture_response.encode() in output,
|
|
831
|
+
'fixtureResponseInScreenModel': fixture_response in screen.visible(),
|
|
832
|
+
'screenExcerpt': screen.visible()[-500:],
|
|
833
|
+
'selectedSubstring': selected_text if selection_sent else None,
|
|
834
|
+
'mouseCoordinates': selection_coordinates if selection_sent else None,
|
|
835
|
+
'controlledHostClipboardUpdates': selection_values,
|
|
836
|
+
'clipboardUpdateDeltaBeforeCopy': selection_values,
|
|
837
|
+
'controlledHostClipboardReadback': selection_readback,
|
|
838
|
+
'physicalOSClipboardVerified': False}
|
|
839
|
+
copy_values = screen.clipboard_history[copy_clipboard_count:] if copy_invoked else []
|
|
840
|
+
copy_readback = screen.query_clipboard() if copy_invoked and copy_values else None
|
|
841
|
+
copy_model_pass = (copy_invoked and copy_values == [fixture_response] and
|
|
842
|
+
copy_readback == fixture_response)
|
|
843
|
+
result['checks']['clipboardOSC52'] = {
|
|
844
|
+
'status': 'PASS' if copy_model_pass else 'UNVERIFIED',
|
|
845
|
+
'reason': 'Controlled host model accepted and queried OSC 52 with the exact distinct latest fixture response; this is not physical OS clipboard proof' if copy_invoked and copy_values == [fixture_response] and copy_readback == fixture_response else 'No exact new OSC 52 clipboard value and readback observed after /copy',
|
|
846
|
+
'copyActionSent': copy_invoked,
|
|
847
|
+
'controlledHostClipboardUpdates': copy_values,
|
|
848
|
+
'clipboardUpdateDeltaAfterSelection': copy_values,
|
|
849
|
+
'controlledHostClipboardReadback': copy_readback,
|
|
850
|
+
'physicalOSClipboardVerified': False}
|
|
851
|
+
incomplete = [name for name, check in result['checks'].items()
|
|
852
|
+
if check['status'] != 'PASS']
|
|
853
|
+
result['status'] = 'INCOMPLETE' if incomplete else 'PASS'
|
|
854
|
+
result['incompleteChecks'] = incomplete
|
|
855
|
+
rendered = json.dumps(result, ensure_ascii=False, indent=2) + '\n'
|
|
856
|
+
print(rendered, end='')
|
|
857
|
+
if args.artifact:
|
|
858
|
+
artifact_path = Path(args.artifact).resolve()
|
|
859
|
+
artifact_path.parent.mkdir(parents=True, exist_ok=True)
|
|
860
|
+
artifact_path.write_text(rendered, encoding='utf8')
|
|
861
|
+
return 2 if incomplete else 0
|
|
862
|
+
finally:
|
|
863
|
+
if child.poll() is None:
|
|
864
|
+
os.killpg(child.pid, signal.SIGTERM)
|
|
865
|
+
try:
|
|
866
|
+
child.wait(timeout=5)
|
|
867
|
+
except subprocess.TimeoutExpired:
|
|
868
|
+
os.killpg(child.pid, signal.SIGKILL)
|
|
869
|
+
child.wait()
|
|
870
|
+
os.close(master)
|
|
871
|
+
server.shutdown()
|
|
872
|
+
server.server_close()
|
|
873
|
+
|
|
874
|
+
|
|
875
|
+
if __name__ == '__main__':
|
|
876
|
+
raise SystemExit(main())
|