@danceiny/gotry 0.0.1-rc.21 → 0.0.1-rc.24

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 (327) hide show
  1. package/README.md +102 -194
  2. package/README.zh-CN.md +115 -210
  3. package/bin/gotry-backend.js +53 -0
  4. package/bin/gotry-bootstrap.js +466 -35
  5. package/bin/gotry-inner.js +255 -36
  6. package/bin/gotry-process-liveness.d.ts +27 -0
  7. package/bin/gotry-process-liveness.js +97 -0
  8. package/bin/gotry-runtime-resolution.d.ts +1 -1
  9. package/bin/gotry-runtime-resolution.js +2 -2
  10. package/client/client.js +174 -0
  11. package/cordis.gotry-patch.yml +70 -93
  12. package/dist/capabilities/agent-reach-deep.js +45 -122
  13. package/dist/capabilities/agent-reach.js +63 -148
  14. package/dist/capabilities/anything.js +89 -75
  15. package/dist/capabilities/artifacts.js +123 -104
  16. package/dist/capabilities/channel-health.js +65 -45
  17. package/dist/capabilities/channel-registry.js +108 -87
  18. package/dist/capabilities/doctor.js +357 -235
  19. package/dist/capabilities/effect.js +223 -309
  20. package/dist/capabilities/fact-log.js +28 -14
  21. package/dist/capabilities/flyai.js +245 -156
  22. package/dist/capabilities/ground-transfer.js +509 -0
  23. package/dist/capabilities/hbcli.js +144 -148
  24. package/dist/capabilities/hotelbyte-transaction.js +194 -0
  25. package/dist/capabilities/incident-log.js +131 -61
  26. package/dist/capabilities/model-override.js +30 -10
  27. package/dist/capabilities/opensky.js +54 -43
  28. package/dist/capabilities/resilience.js +50 -32
  29. package/dist/capabilities/session/action-cache.js +50 -44
  30. package/dist/capabilities/session/adapters/ctrip-flight.js +119 -62
  31. package/dist/capabilities/session/adapters/ctrip-hotel.js +125 -110
  32. package/dist/capabilities/session/adapters/dida-portal.js +206 -0
  33. package/dist/capabilities/session/adapters/meituan-local.js +35 -54
  34. package/dist/capabilities/session/adapters/rail-12306.js +169 -112
  35. package/dist/capabilities/session/benchmark.js +85 -149
  36. package/dist/capabilities/session/extension-bridge.js +320 -259
  37. package/dist/capabilities/session/extension-channel.js +57 -76
  38. package/dist/capabilities/session/extension-distribution.js +115 -119
  39. package/dist/capabilities/session/extract.js +31 -21
  40. package/dist/capabilities/session/golden-score.js +55 -33
  41. package/dist/capabilities/session/health-watch.js +146 -154
  42. package/dist/capabilities/session/read-guard.js +55 -47
  43. package/dist/capabilities/session/static-flight-golden.js +49 -36
  44. package/dist/capabilities/session/transport.js +54 -61
  45. package/dist/capabilities/session/wizard.js +63 -93
  46. package/dist/capabilities/session-consent.js +143 -47
  47. package/dist/capabilities/session-login.js +95 -126
  48. package/dist/capabilities/session-search.js +391 -253
  49. package/dist/capabilities/sponsor-plugin.js +341 -0
  50. package/dist/capabilities/visa-policy.js +87 -73
  51. package/dist/capabilities/weather.js +163 -235
  52. package/dist/scripts/action-cache-tests.js +24 -59
  53. package/dist/scripts/agent-planning-turn-deadline-e2e.js +91 -142
  54. package/dist/scripts/agent-planning-turn-deadline-tests.js +80 -176
  55. package/dist/scripts/agent-reach-deep-tests.js +30 -40
  56. package/dist/scripts/agent-reach-tests.js +26 -18
  57. package/dist/scripts/agent-reach-wrapper-tests.js +46 -50
  58. package/dist/scripts/anything-tests.js +58 -62
  59. package/dist/scripts/artifact-client-contract-tests.js +69 -0
  60. package/dist/scripts/artifacts-capability-tests.js +235 -0
  61. package/dist/scripts/async-collect.js +26 -15
  62. package/dist/scripts/benchmark-environment-bridge-e2e.js +287 -684
  63. package/dist/scripts/benchmark-environment-bridge-tests.js +818 -3123
  64. package/dist/scripts/booking-copilot-availability-ledger-binding-tests.js +66 -279
  65. package/dist/scripts/booking-copilot-availability-policy-tests.js +153 -1257
  66. package/dist/scripts/booking-copilot-bin-proof-tests.js +22 -45
  67. package/dist/scripts/booking-copilot-crossrepo-fixture-server.js +64 -76
  68. package/dist/scripts/booking-copilot-dsh-core-proof-tests.js +69 -199
  69. package/dist/scripts/booking-copilot-dsh-planner-proof-tests.js +393 -759
  70. package/dist/scripts/booking-copilot-dsh-plugin-proof-tests.js +33 -75
  71. package/dist/scripts/booking-copilot-event-sequence-concurrency-proof-tests.js +60 -110
  72. package/dist/scripts/booking-copilot-gap-code-contract-proof-tests.js +28 -66
  73. package/dist/scripts/booking-copilot-operation-ledger-concurrency-proof-tests.js +72 -271
  74. package/dist/scripts/booking-copilot-receipt-ledger-concurrency-proof-tests.js +73 -275
  75. package/dist/scripts/booking-copilot-runtime-proof-tests.js +784 -3398
  76. package/dist/scripts/booking-copilot-server-proof-tests.js +63 -168
  77. package/dist/scripts/booking-copilot-startup-proof-tests.js +38 -124
  78. package/dist/scripts/booking-executor-tests.js +80 -0
  79. package/dist/scripts/booking-recovery-chain-tests.js +182 -0
  80. package/dist/scripts/booking-saga-tests.js +61 -123
  81. package/dist/scripts/booking-surface-contract-proof-tests.js +152 -1167
  82. package/dist/scripts/bootstrap-tests.js +1383 -128
  83. package/dist/scripts/build-changelog.js +89 -130
  84. package/dist/scripts/build-metrics-report.js +135 -143
  85. package/dist/scripts/changelog-tests.js +35 -82
  86. package/dist/scripts/channel-probe-tests.js +44 -116
  87. package/dist/scripts/channel-probe.js +84 -167
  88. package/dist/scripts/channel-registry-tests.js +97 -185
  89. package/dist/scripts/companion-tests.js +25 -70
  90. package/dist/scripts/dida-cookie-probe.js +87 -0
  91. package/dist/scripts/dida-sw-probe.js +114 -0
  92. package/dist/scripts/diff-test.js +17 -12
  93. package/dist/scripts/doctor-tests.js +428 -120
  94. package/dist/scripts/dsh-artifact-e2e.js +150 -0
  95. package/dist/scripts/dsh-artifact-web-e2e.js +443 -0
  96. package/dist/scripts/dsh-http-proxy-sse-proof.js +90 -0
  97. package/dist/scripts/dsh-runtime-closure-tests.js +110 -153
  98. package/dist/scripts/dsh-runtime-closure.js +75 -63
  99. package/dist/scripts/dsh-session-v3-migration-proof.js +106 -0
  100. package/dist/scripts/dsh-subprocess-local-proof.js +149 -0
  101. package/dist/scripts/dsh-target-closure-proof.js +178 -0
  102. package/dist/scripts/effect-tests.js +165 -552
  103. package/dist/scripts/engine-run.js +1 -3
  104. package/dist/scripts/engine-tests.js +16 -10
  105. package/dist/scripts/evaluation-cadence-tests.js +69 -242
  106. package/dist/scripts/evaluation-contract-tests.js +193 -499
  107. package/dist/scripts/extension-distribution-cli.js +19 -15
  108. package/dist/scripts/extension-distribution-tests.js +98 -211
  109. package/dist/scripts/extension-tests.js +366 -389
  110. package/dist/scripts/fact-gate-tests.js +725 -263
  111. package/dist/scripts/fixtures/extension-bridge-unref-child.mjs +21 -0
  112. package/dist/scripts/flight-hotel-anchor-363-registered-e2e.js +169 -0
  113. package/dist/scripts/flight-malformed-tests.js +159 -0
  114. package/dist/scripts/flyai-tests.js +325 -68
  115. package/dist/scripts/g5-guard-tests.js +131 -0
  116. package/dist/scripts/g5-guard.js +189 -0
  117. package/dist/scripts/gen-lunar-anchors.js +106 -0
  118. package/dist/scripts/gotry-backend-kernel-tests.js +79 -0
  119. package/dist/scripts/ground-transfer-tests.js +956 -0
  120. package/dist/scripts/hbcli-e2e-tests.js +71 -90
  121. package/dist/scripts/hbcli-live-optin-tests.js +96 -0
  122. package/dist/scripts/hbcli-release-contract-tests.js +122 -0
  123. package/dist/scripts/hbcli-tests.js +41 -115
  124. package/dist/scripts/health-watch-cli.js +42 -28
  125. package/dist/scripts/hotel-date-gate-tests.js +496 -0
  126. package/dist/scripts/hotelbyte-reconcile-tests.js +141 -0
  127. package/dist/scripts/hotelbyte-spawn-e2e-tests.js +373 -0
  128. package/dist/scripts/i18n-tests.js +16 -9
  129. package/dist/scripts/incident-tests.js +281 -87
  130. package/dist/scripts/issue-194-job-id-guard-tests.js +106 -0
  131. package/dist/scripts/issue-233-cancel-refund-commission-tests.js +392 -0
  132. package/dist/scripts/issue-271-liveness-tests.js +547 -0
  133. package/dist/scripts/issue-289-model-retry-real-tests.js +296 -0
  134. package/dist/scripts/issue-289-model-retry-tests.js +441 -0
  135. package/dist/scripts/issue-289-web-retry-e2e.js +630 -0
  136. package/dist/scripts/issue-338-home-city-e2e.js +577 -0
  137. package/dist/scripts/issue343-real-entry-e2e.js +160 -0
  138. package/dist/scripts/journey-tests.js +22 -27
  139. package/dist/scripts/kernel-manifest-evidence.js +109 -0
  140. package/dist/scripts/kernel-manifest-gate.js +126 -0
  141. package/dist/scripts/kernel-manifest-tests.js +212 -0
  142. package/dist/scripts/kernel-manifest-trace-entry.js +7 -0
  143. package/dist/scripts/kernel-manifest-trace-hook.mjs +28 -0
  144. package/dist/scripts/kernel-manifest.js +369 -0
  145. package/dist/scripts/ledger-repair-apply-tests.js +316 -0
  146. package/dist/scripts/ledger-repair-plan-tests.js +375 -0
  147. package/dist/scripts/ledger-tests.js +325 -340
  148. package/dist/scripts/ledger-workflow-crash.js +10 -20
  149. package/dist/scripts/map-tools-vendor-package-proof.js +206 -134
  150. package/dist/scripts/memory-capture-tests.js +242 -61
  151. package/dist/scripts/memory-decay-tests.js +18 -52
  152. package/dist/scripts/memory-lifecycle-tests.js +657 -0
  153. package/dist/scripts/memory-lifecycle.js +196 -0
  154. package/dist/scripts/memory-metrics.js +24 -12
  155. package/dist/scripts/memory-value-report.js +474 -222
  156. package/dist/scripts/memory-value-tests.js +328 -0
  157. package/dist/scripts/metrics-report-tests.js +62 -199
  158. package/dist/scripts/model-override-e2e.js +66 -105
  159. package/dist/scripts/nightly-evidence-tests.js +64 -89
  160. package/dist/scripts/nightly-evidence.js +108 -111
  161. package/dist/scripts/nudge-digest.js +39 -28
  162. package/dist/scripts/onboarding-tests.js +63 -88
  163. package/dist/scripts/opensky-check.js +18 -13
  164. package/dist/scripts/opensky-tests.js +28 -28
  165. package/dist/scripts/persona-surface-guard-tests.js +37 -12
  166. package/dist/scripts/pnpm-dsh-closure-proof.js +2 -5
  167. package/dist/scripts/policy-anchor-359-registered-e2e.js +214 -0
  168. package/dist/scripts/price-drift-tests.js +126 -227
  169. package/dist/scripts/price-drift-watch.js +209 -253
  170. package/dist/scripts/probe-poi-tests.js +52 -45
  171. package/dist/scripts/product-metrics.js +187 -273
  172. package/dist/scripts/publish-preverify.js +47 -41
  173. package/dist/scripts/realtime-pricing-tests.js +37 -76
  174. package/dist/scripts/replay-async.js +20 -18
  175. package/dist/scripts/replay-real.js +16 -18
  176. package/dist/scripts/replay.js +52 -82
  177. package/dist/scripts/session-attach-diagnose.js +16 -12
  178. package/dist/scripts/session-attach-poc.js +34 -37
  179. package/dist/scripts/session-benchmark.js +66 -147
  180. package/dist/scripts/session-dida-live-e2e.js +151 -0
  181. package/dist/scripts/session-extract-tests.js +14 -35
  182. package/dist/scripts/session-login.js +14 -13
  183. package/dist/scripts/session-tests.js +545 -583
  184. package/dist/scripts/sf-live-benchmark.js +92 -110
  185. package/dist/scripts/sf-live-cli-tests.js +2 -9
  186. package/dist/scripts/sf-soft-score-tests.js +19 -60
  187. package/dist/scripts/sf-summary-tests.js +149 -0
  188. package/dist/scripts/sf-summary.js +466 -84
  189. package/dist/scripts/skeleton-check.js +16 -14
  190. package/dist/scripts/skeleton-integration-test.js +11 -10
  191. package/dist/scripts/skills-contract-tests.js +51 -46
  192. package/dist/scripts/smoke-session-gate-tests.js +12 -20
  193. package/dist/scripts/smoke.js +541 -422
  194. package/dist/scripts/sponsor-reuse-tests.js +262 -0
  195. package/dist/scripts/state-cli-tests.js +226 -101
  196. package/dist/scripts/state-cli.js +549 -212
  197. package/dist/scripts/static-golden-tests.js +46 -180
  198. package/dist/scripts/time-eval-tests.js +210 -192
  199. package/dist/scripts/travel-timeline-tests.js +29 -93
  200. package/dist/scripts/turn-handoff-collect-tests.js +46 -93
  201. package/dist/scripts/turn-handoff-collect.js +71 -99
  202. package/dist/scripts/turn-policy-tests.js +28 -29
  203. package/dist/scripts/typed-contract-canary.js +77 -162
  204. package/dist/scripts/tz-resolver-sanity.js +50 -0
  205. package/dist/scripts/tz-resolver-tests.js +584 -0
  206. package/dist/scripts/unified-tests.js +23 -19
  207. package/dist/scripts/visa-policy-tests.js +31 -44
  208. package/dist/scripts/weather-tests.js +121 -543
  209. package/dist/scripts/wish-channel-gate-tests.js +29 -86
  210. package/dist/scripts/write-gate-tests.js +522 -0
  211. package/dist/scripts/z3-lifecycle-fault-tests.js +188 -0
  212. package/dist/scripts/z3-lifecycle-tests.js +170 -0
  213. package/dist/scripts/z3-race-repeat-tests.js +39 -0
  214. package/dist/scripts/z3-race-tests.js +18 -12
  215. package/dist/src/artifact-gate.js +652 -221
  216. package/dist/src/backend/kernel.js +91 -0
  217. package/dist/src/backend/modules/booking-copilot.js +55 -0
  218. package/dist/src/backend/modules/booking-executor.js +124 -0
  219. package/dist/src/backend/modules/session-search.js +194 -0
  220. package/dist/src/benchmark-agent-conformance.js +296 -262
  221. package/dist/src/benchmark-environment-bridge.js +298 -413
  222. package/dist/src/benchmark-headless-child-diagnostics.js +93 -100
  223. package/dist/src/benchmark-tool-isolation.js +94 -67
  224. package/dist/src/bookable-facts.js +299 -174
  225. package/dist/src/booking-saga.js +64 -120
  226. package/dist/src/booking-surface/availability-policy.js +443 -576
  227. package/dist/src/booking-surface/cancel-refund-commission.js +379 -0
  228. package/dist/src/booking-surface/contracts.js +24 -80
  229. package/dist/src/booking-surface/dsh-planner.js +510 -281
  230. package/dist/src/booking-surface/dsh-plugin.js +17 -1
  231. package/dist/src/booking-surface/error-codes.js +82 -10
  232. package/dist/src/booking-surface/index.js +0 -3
  233. package/dist/src/booking-surface/profile.js +10 -54
  234. package/dist/src/booking-surface/recovery-chain.js +367 -0
  235. package/dist/src/booking-surface/runtime.js +765 -891
  236. package/dist/src/booking-surface/server.js +200 -251
  237. package/dist/src/booking-surface/startup.js +58 -54
  238. package/dist/src/booking-surface/validation.js +150 -218
  239. package/dist/src/bridge.js +20 -13
  240. package/dist/src/companions.js +48 -74
  241. package/dist/src/contracts.js +21 -43
  242. package/dist/src/dsh-llm.js +82 -120
  243. package/dist/src/engine.js +253 -239
  244. package/dist/src/evaluation-cadence.js +72 -209
  245. package/dist/src/evaluation-contracts.js +220 -843
  246. package/dist/src/flight-pack-adapter.js +54 -0
  247. package/dist/src/flight-pack-contract.js +2 -0
  248. package/dist/src/gotry-backend.js +70 -0
  249. package/dist/src/hotel-date-gate.js +118 -0
  250. package/dist/src/i18n.js +18 -7
  251. package/dist/src/index.js +1425 -1629
  252. package/dist/src/journey.js +132 -81
  253. package/dist/src/ledger-repair-apply.js +538 -0
  254. package/dist/src/ledger-repair-plan.js +463 -0
  255. package/dist/src/loop.js +257 -166
  256. package/dist/src/memory-capture.js +180 -32
  257. package/dist/src/memory-decay.js +30 -14
  258. package/dist/src/memory-lifecycle.js +1310 -0
  259. package/dist/src/memory-utility.js +27 -40
  260. package/dist/src/mock-llm.js +36 -61
  261. package/dist/src/model.js +81 -36
  262. package/dist/src/realtime-pricing.js +63 -72
  263. package/dist/src/slot-spec.js +84 -106
  264. package/dist/src/state-ledger.js +310 -471
  265. package/dist/src/subagent-job-id-guard.js +58 -0
  266. package/dist/src/time-anchor.js +231 -33
  267. package/dist/src/tool-packet.js +20 -8
  268. package/dist/src/travel-slots.js +58 -35
  269. package/dist/src/travel-timeline.js +44 -63
  270. package/dist/src/turn-deadline.js +121 -127
  271. package/dist/src/turn-policy.js +61 -89
  272. package/dist/src/tz-resolver.js +143 -0
  273. package/dist/src/unified.js +504 -381
  274. package/dist/src/wish-pool.js +39 -14
  275. package/dist/src/write-gate.js +495 -0
  276. package/dist/src/z3-shared.js +273 -7
  277. package/extension/README.md +4 -3
  278. package/extension/background.js +125 -14
  279. package/extension/content-bridge.js +23 -1
  280. package/extension/content-main.js +15 -4
  281. package/extension/manifest.json +16 -8
  282. package/package.json +251 -220
  283. package/ts/capabilities/agent-reach.ts +4 -4
  284. package/ts/capabilities/anything.ts +1 -1
  285. package/ts/capabilities/artifacts.ts +46 -9
  286. package/ts/capabilities/flyai.ts +156 -37
  287. package/ts/capabilities/hbcli.ts +1 -1
  288. package/ts/capabilities/incident-log.ts +88 -59
  289. package/ts/capabilities/session/adapters/ctrip-flight.ts +99 -36
  290. package/ts/capabilities/session/adapters/dida-portal.ts +250 -0
  291. package/ts/capabilities/session/adapters/rail-12306.ts +123 -24
  292. package/ts/capabilities/session/extension-bridge.ts +226 -101
  293. package/ts/capabilities/session/extension-channel.ts +22 -9
  294. package/ts/capabilities/session/wizard.ts +2 -2
  295. package/ts/capabilities/session-consent.ts +98 -14
  296. package/ts/capabilities/session-login.ts +1 -0
  297. package/ts/capabilities/session-search.ts +315 -31
  298. package/ts/dsh-runtime/vendor/README.md +8 -4
  299. package/ts/dsh-runtime/vendor/dsh-map-tools/README.en.md +7 -1
  300. package/ts/dsh-runtime/vendor/dsh-map-tools/README.md +5 -3
  301. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/settings-ns.js +1 -1
  302. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/settings-ns.d.ts +1 -1
  303. package/ts/dsh-runtime/vendor/dsh-map-tools/package.json +6 -6
  304. package/ts/package.json +236 -16
  305. package/ts/scripts/map-tools-vendor-package-proof.ts +152 -31
  306. package/ts/scripts/state-cli.ts +418 -51
  307. package/ts/src/artifact-gate.ts +561 -15
  308. package/ts/src/benchmark-environment-bridge.ts +37 -20
  309. package/ts/src/bookable-facts.ts +183 -7
  310. package/ts/src/booking-surface/cancel-refund-commission.ts +488 -0
  311. package/ts/src/booking-surface/dsh-planner.ts +240 -32
  312. package/ts/src/booking-surface/dsh-plugin.js +17 -1
  313. package/ts/src/booking-surface/error-codes.ts +75 -0
  314. package/ts/src/booking-surface/recovery-chain.ts +409 -0
  315. package/ts/src/booking-surface/server.ts +84 -51
  316. package/ts/src/booking-surface/validation.ts +2 -0
  317. package/ts/src/contracts.ts +12 -2
  318. package/ts/src/dsh-llm.ts +4 -8
  319. package/ts/src/index.ts +609 -90
  320. package/ts/src/loop.ts +116 -7
  321. package/ts/src/memory-capture.ts +227 -3
  322. package/ts/src/mock-llm.ts +4 -8
  323. package/ts/src/model.ts +88 -2
  324. package/ts/src/state-ledger.ts +47 -29
  325. package/ts/src/subagent-job-id-guard.ts +72 -0
  326. package/ts/src/time-anchor.ts +217 -13
  327. package/ts/src/unified.ts +316 -54
@@ -0,0 +1,630 @@
1
+ /**
2
+ * Issue #289 bounded current-Web retry/cancel E2E.
3
+ *
4
+ * Pinned acceptance for this proof:
5
+ * A. A fresh packaged GoTry consumer drives the public Web UI through a
6
+ * localhost OpenAI-compatible relay: 429 -> visible scheduled retry ->
7
+ * visible retry-started -> terminal assistant success.
8
+ * B. A second fresh Web turn is visibly in retry backoff; the real public
9
+ * Stop control cancels it, the UI shows the target retry as cancelled
10
+ * and the session is idle, and after a drain longer than the backoff the
11
+ * relay request count is unchanged.
12
+ * C. Every assertion is isolated from user HOME, DSH_HOME, state,
13
+ * workspace, browser profile, providers, credentials, and booking writes.
14
+ *
15
+ * This is deliberately a packaged-public-path proof. It does not import or
16
+ * call internal agent cancellation APIs, patch product/vendor code, or use a
17
+ * real provider. Run from ts/ with Node >= 24:
18
+ * npx tsx scripts/issue-289-web-retry-e2e.ts
19
+ */
20
+ import assert from 'node:assert/strict';
21
+ import { createHash } from 'node:crypto';
22
+ import { createServer } from 'node:http';
23
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
24
+ import { tmpdir } from 'node:os';
25
+ import { basename, join } from 'node:path';
26
+ import { spawn } from 'node:child_process';
27
+ import { promisify } from 'node:util';
28
+ import { execFile as execFileCallback } from 'node:child_process';
29
+ import puppeteer from 'puppeteer-core';
30
+ const execFile = promisify(execFileCallback);
31
+ const ROOT = join(import.meta.dirname, '..', '..');
32
+ const CHROME = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
33
+ const BACKOFF_MS = 10_000;
34
+ const CANCEL_DRAIN_MS = BACKOFF_MS + 1_000;
35
+ const STARTUP_TIMEOUT_MS = 120_000;
36
+ const UI_TIMEOUT_MS = 60_000;
37
+ const RETRY_STATE_ROW_PATTERN = /(?:正在重试模型请求|等待重试模型请求|已重试模型请求|模型请求重试已取消|Retrying model request|Waiting to retry model request|Retried model request|Model request retry cancelled)/i;
38
+ function sse(data) {
39
+ return `data: ${JSON.stringify(data)}\n\n`;
40
+ }
41
+ function textResponse(id, text) {
42
+ return sse({
43
+ id,
44
+ object: 'chat.completion.chunk',
45
+ created: 1,
46
+ model: 'synthetic-issue-289',
47
+ choices: [{ index: 0, delta: { role: 'assistant', content: text }, finish_reason: null }],
48
+ }) + sse({
49
+ id: `${id}-finish`,
50
+ object: 'chat.completion.chunk',
51
+ created: 1,
52
+ model: 'synthetic-issue-289',
53
+ choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],
54
+ }) + 'data: [DONE]\n\n';
55
+ }
56
+ function contentText(content) {
57
+ if (typeof content === 'string')
58
+ return content;
59
+ if (!Array.isArray(content)) {
60
+ if (content && typeof content === 'object' && typeof content.text === 'string')
61
+ return content.text;
62
+ return '';
63
+ }
64
+ return content.map(item => contentText(item)).join('');
65
+ }
66
+ function latestUserMarker(body) {
67
+ const messages = Array.isArray(body.messages) ? body.messages : [];
68
+ for (const message of [...messages].reverse()) {
69
+ if (!message || typeof message !== 'object' || message.role !== 'user')
70
+ continue;
71
+ const text = contentText(message.content);
72
+ if (text.includes('ISSUE289_SCENARIO_A'))
73
+ return { scenario: 'A', marker: 'ISSUE289_SCENARIO_A' };
74
+ if (text.includes('ISSUE289_SCENARIO_B'))
75
+ return { scenario: 'B', marker: 'ISSUE289_SCENARIO_B' };
76
+ }
77
+ return { scenario: null, marker: null };
78
+ }
79
+ function classifyRequest(body) {
80
+ const primary = Array.isArray(body.tools) && body.tools.length > 0;
81
+ const messages = Array.isArray(body.messages) ? body.messages : [];
82
+ const messageText = messages.map(message => message && typeof message === 'object' ? contentText(message.content) : '').join('\n');
83
+ const systemText = contentText(body.system);
84
+ const titlePrompt = !primary && body.max_tokens === 64 && (systemText.includes('Create a concise title for an AI coding-assistant session') ||
85
+ messageText.includes('Generate the session title from this JSON array of human messages'));
86
+ if (primary) {
87
+ const target = latestUserMarker(body);
88
+ return { kind: 'primary', scenario: target.scenario, marker: target.marker };
89
+ }
90
+ return { kind: titlePrompt ? 'title' : 'auxiliary', scenario: null, marker: null };
91
+ }
92
+ async function startRelay() {
93
+ const records = [];
94
+ const counts = { A: 0, B: 0 };
95
+ const server = createServer((req, res) => {
96
+ if (req.method !== 'POST' || !req.url?.endsWith('/chat/completions')) {
97
+ res.writeHead(404).end('not found');
98
+ return;
99
+ }
100
+ const chunks = [];
101
+ req.on('data', chunk => chunks.push(Buffer.from(chunk)));
102
+ req.on('end', () => {
103
+ let body = {};
104
+ try {
105
+ body = JSON.parse(Buffer.concat(chunks).toString('utf8'));
106
+ }
107
+ catch { /* dsh reports malformed upstream */ }
108
+ const request = classifyRequest(body);
109
+ const scenario = request.kind === 'primary' ? request.scenario : null;
110
+ const ordinal = scenario === null ? null : counts[scenario]++;
111
+ const status = request.kind === 'primary' && scenario !== null && ordinal === 0 ? 429 : 200;
112
+ records.push({ kind: request.kind, scenario, ordinal, userMarker: request.marker, at: new Date().toISOString(), status });
113
+ if (status === 429) {
114
+ res.writeHead(429, {
115
+ 'content-type': 'application/json',
116
+ 'retry-after': String(BACKOFF_MS / 1_000),
117
+ 'x-request-id': `issue-289-${scenario}-initial`,
118
+ });
119
+ res.end(JSON.stringify({ error: { code: 'RATE_LIMIT', message: `synthetic issue-289 scenario ${scenario} rate limit`, status: 429 } }));
120
+ return;
121
+ }
122
+ res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache', connection: 'keep-alive' });
123
+ if (request.kind === 'primary' && scenario === 'A' && ordinal !== null && ordinal >= 1) {
124
+ // Hold the successful retry stream briefly so the public UI's
125
+ // retry-started state remains observable before terminal success.
126
+ setTimeout(() => res.end(textResponse(`issue-289-a-success-${ordinal}`, 'ISSUE289_SCENARIO_A_SUCCESS')), 1_500);
127
+ }
128
+ else if (request.kind === 'primary' && scenario === 'B' && ordinal !== null && ordinal >= 1) {
129
+ // This response is intentionally reachable only if cancellation fails.
130
+ res.end(textResponse('issue-289-b-unexpected-success', 'ISSUE289_SCENARIO_B_UNEXPECTED_RETRY'));
131
+ }
132
+ else {
133
+ // Non-scenario requests (including the one-time title warm-up) never
134
+ // leave the localhost relay and cannot change either scenario's
135
+ // count. Keep the response non-empty so the warm-up turn closes.
136
+ res.end(textResponse(`issue-289-aux-${records.length}`, 'ISSUE289_WARMUP_SUCCESS'));
137
+ }
138
+ });
139
+ });
140
+ await new Promise((resolve, reject) => {
141
+ server.once('error', reject);
142
+ server.listen(0, '127.0.0.1', resolve);
143
+ });
144
+ return {
145
+ port: server.address().port,
146
+ records,
147
+ close: () => new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve())),
148
+ };
149
+ }
150
+ async function run(command, args, cwd, env) {
151
+ const result = await execFile(command, args, { cwd, env, maxBuffer: 16 * 1024 * 1024 });
152
+ return result.stdout;
153
+ }
154
+ function waitForUrl(child, pattern, timeoutMs) {
155
+ return new Promise((resolve, reject) => {
156
+ let output = '';
157
+ const timer = setTimeout(() => {
158
+ child.stdout.off('data', onData);
159
+ child.stderr.off('data', onData);
160
+ reject(new Error(`timed out waiting for ${pattern}; output=${output.slice(-4000)}`));
161
+ }, timeoutMs);
162
+ const onData = (chunk) => {
163
+ output += chunk.toString();
164
+ const match = output.match(pattern);
165
+ if (!match)
166
+ return;
167
+ clearTimeout(timer);
168
+ child.stdout.off('data', onData);
169
+ child.stderr.off('data', onData);
170
+ resolve(match[1]);
171
+ };
172
+ child.stdout.on('data', onData);
173
+ child.stderr.on('data', onData);
174
+ });
175
+ }
176
+ async function waitUntil(predicate, timeoutMs, description) {
177
+ const deadline = Date.now() + timeoutMs;
178
+ while (!predicate() && Date.now() < deadline)
179
+ await new Promise(resolve => setTimeout(resolve, 25));
180
+ assert.ok(predicate(), `${description} did not settle within ${timeoutMs}ms`);
181
+ }
182
+ async function terminateServer(child) {
183
+ if (!child || child.exitCode !== null)
184
+ return;
185
+ const groupPid = process.platform === 'win32' ? null : child.pid;
186
+ const signal = (name) => {
187
+ if (groupPid) {
188
+ try {
189
+ process.kill(-groupPid, name);
190
+ return;
191
+ }
192
+ catch { /* fall back to leader */ }
193
+ }
194
+ try {
195
+ child.kill(name);
196
+ }
197
+ catch { /* already exited */ }
198
+ };
199
+ signal('SIGTERM');
200
+ await new Promise(resolve => {
201
+ const timer = setTimeout(() => {
202
+ signal('SIGKILL');
203
+ resolve();
204
+ }, 2_000);
205
+ child.once('close', () => { clearTimeout(timer); resolve(); });
206
+ });
207
+ }
208
+ async function processTable() {
209
+ const { stdout } = await execFile('ps', ['-axo', 'pid=,ppid=,command=']);
210
+ return stdout.split('\n').flatMap(line => {
211
+ const fields = line.trim().split(/\s+/);
212
+ const pid = Number(fields.shift());
213
+ const ppid = Number(fields.shift());
214
+ if (!Number.isInteger(pid) || !Number.isInteger(ppid))
215
+ return [];
216
+ return [{ pid, ppid, command: fields.join(' ') }];
217
+ });
218
+ }
219
+ async function ownedProcessIds(serverPid, ownedPaths) {
220
+ const rows = await processTable();
221
+ const ids = new Set();
222
+ for (const row of rows) {
223
+ if ((serverPid !== undefined && row.pid === serverPid) || ownedPaths.some(path => row.command.includes(path)))
224
+ ids.add(row.pid);
225
+ }
226
+ let changed = true;
227
+ while (changed) {
228
+ changed = false;
229
+ for (const row of rows) {
230
+ if (ids.has(row.ppid) && !ids.has(row.pid)) {
231
+ ids.add(row.pid);
232
+ changed = true;
233
+ }
234
+ }
235
+ }
236
+ return [...ids].sort((left, right) => right - left);
237
+ }
238
+ async function reapOwnedProcesses(serverPid, ownedPaths) {
239
+ let remaining = await ownedProcessIds(serverPid, ownedPaths);
240
+ for (const pid of remaining) {
241
+ try {
242
+ process.kill(pid, 'SIGTERM');
243
+ }
244
+ catch { /* already exited */ }
245
+ }
246
+ const termDeadline = Date.now() + 2_000;
247
+ while (remaining.length > 0 && Date.now() < termDeadline) {
248
+ await new Promise(resolve => setTimeout(resolve, 25));
249
+ remaining = await ownedProcessIds(serverPid, ownedPaths);
250
+ }
251
+ for (const pid of remaining) {
252
+ try {
253
+ process.kill(pid, 'SIGKILL');
254
+ }
255
+ catch { /* already exited */ }
256
+ }
257
+ const killDeadline = Date.now() + 2_000;
258
+ while (remaining.length > 0 && Date.now() < killDeadline) {
259
+ await new Promise(resolve => setTimeout(resolve, 25));
260
+ remaining = await ownedProcessIds(serverPid, ownedPaths);
261
+ }
262
+ return remaining;
263
+ }
264
+ function sha256(path) {
265
+ return createHash('sha256').update(readFileSync(path)).digest('hex');
266
+ }
267
+ function redactSecrets(text) {
268
+ return text
269
+ .replace(/([?&]token=)[^&\s)]+/g, '$1[REDACTED]')
270
+ .replace(/synthetic-issue-289-key/g, '[REDACTED_KEY]');
271
+ }
272
+ async function main() {
273
+ assert.ok(Number.parseInt(process.versions.node.split('.')[0], 10) >= 24, `Node >=24 required, got ${process.version}`);
274
+ assert.ok(existsSync(CHROME), `Chrome missing: ${CHROME}`);
275
+ const baseSha = (await run('git', ['rev-parse', 'HEAD'], ROOT, process.env)).trim();
276
+ assert.match(baseSha, /^[0-9a-f]{40}$/, `unexpected HEAD SHA: ${baseSha}`);
277
+ const outputDir = process.env.GOTRY_ISSUE_289_WEB_E2E_OUT || join(ROOT, '.omx', 'artifacts', 'issue-289-web-retry-e2e');
278
+ mkdirSync(outputDir, { recursive: true });
279
+ const packageDir = mkdtempSync(join(tmpdir(), 'gotry-issue-289-web-pack-'));
280
+ const consumerDir = mkdtempSync(join(tmpdir(), 'gotry-issue-289-web-consumer-'));
281
+ const workspaceDir = mkdtempSync(join(tmpdir(), 'gotry-issue-289-web-workspace-'));
282
+ const homeDir = mkdtempSync(join(tmpdir(), 'gotry-issue-289-web-home-'));
283
+ const dshHome = mkdtempSync(join(tmpdir(), 'gotry-issue-289-web-dsh-'));
284
+ const userDataDir = mkdtempSync(join(tmpdir(), 'gotry-issue-289-web-chrome-'));
285
+ const relay = await startRelay();
286
+ const browserProfile = userDataDir;
287
+ let browser = null;
288
+ let page = null;
289
+ let server = null;
290
+ let passed = false;
291
+ let serverOutput = '';
292
+ const assertions = {
293
+ baseSha,
294
+ node: process.version,
295
+ isolation: {
296
+ home: homeDir,
297
+ dshHome,
298
+ workspace: workspaceDir,
299
+ consumer: consumerDir,
300
+ browserProfile,
301
+ liveFlags: { GOTRY_SESSION_LIVE: '0', GOTRY_HBCLI_LIVE: '0', GOTRY_HOTELBYTE_SKILLS_LIVE: '0' },
302
+ relay: '127.0.0.1 only',
303
+ syntheticKey: true,
304
+ bookingWrite: false,
305
+ },
306
+ };
307
+ try {
308
+ writeFileSync(join(consumerDir, 'package.json'), JSON.stringify({ private: true, dependencies: {} }, null, 2) + '\n', { mode: 0o600 });
309
+ await run(process.execPath, [join(ROOT, 'scripts/build-dist.mjs')], ROOT, process.env);
310
+ await run('npm', ['pack', '--ignore-scripts', '--pack-destination', packageDir], ROOT, process.env);
311
+ const tarballName = readdirSync(packageDir).find(name => name.endsWith('.tgz'));
312
+ assert.ok(tarballName, 'npm pack did not produce a GoTry tarball');
313
+ const tarball = join(packageDir, tarballName);
314
+ const packageJson = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8'));
315
+ const npmEnv = { ...process.env, HOME: homeDir };
316
+ await run('npm', ['install', '--prefer-offline', '--ignore-scripts', '--no-audit', '--no-fund', tarball], consumerDir, npmEnv);
317
+ const installedRoot = join(consumerDir, 'node_modules', '@danceiny', 'gotry');
318
+ const installedBin = join(installedRoot, 'bin', 'gotry-inner.js');
319
+ assert.ok(existsSync(installedBin), 'packed GoTry binary missing');
320
+ assert.ok(existsSync(join(installedRoot, 'client', 'client.js')), 'packed public client missing');
321
+ assertions.package = { version: packageJson.version, tarball: basename(tarball), installedClient: join(installedRoot, 'client', 'client.js') };
322
+ const env = {
323
+ ...npmEnv,
324
+ DSH_HOME: dshHome,
325
+ GOTRY_ONBOARDING_SKIP: '1',
326
+ GOTRY_SETUP_SKIP: '1',
327
+ GOTRY_SESSION_LIVE: '0',
328
+ GOTRY_HBCLI_LIVE: '0',
329
+ GOTRY_HOTELBYTE_SKILLS_LIVE: '0',
330
+ LLM_API_KEY: 'synthetic-issue-289-key',
331
+ LLM_BASE_URL: `http://127.0.0.1:${relay.port}/v1`,
332
+ LLM_MODEL: 'synthetic-issue-289',
333
+ };
334
+ const child = spawn(process.execPath, [installedBin, 'web', '--no-open', '--port', '0'], {
335
+ cwd: workspaceDir,
336
+ env,
337
+ detached: process.platform !== 'win32',
338
+ stdio: ['ignore', 'pipe', 'pipe'],
339
+ });
340
+ server = child;
341
+ child.stdout.on('data', chunk => { serverOutput += chunk.toString(); });
342
+ child.stderr.on('data', chunk => { serverOutput += chunk.toString(); });
343
+ const url = await waitForUrl(child, /dsh web: (http:\/\/127\.0\.0\.1:\d+\/\?token=[^\s)]+)/, STARTUP_TIMEOUT_MS);
344
+ assertions.server = { authenticatedUrlCaptured: true, urlHost: new URL(url).host, port: new URL(url).port };
345
+ browser = await puppeteer.launch({ executablePath: CHROME, headless: true, userDataDir, args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'] });
346
+ page = await browser.newPage();
347
+ page.on('console', (message) => { serverOutput += `\n[browser console ${message.type()}] ${message.text()}`; });
348
+ page.on('pageerror', (error) => { serverOutput += `\n[browser pageerror] ${error.stack || error.message}`; });
349
+ page.setDefaultTimeout(UI_TIMEOUT_MS);
350
+ await page.goto(url, { waitUntil: 'networkidle2', timeout: 30_000 });
351
+ const dismissDisclosure = async () => {
352
+ const buttons = await page.$$('button');
353
+ for (const button of buttons) {
354
+ const control = await button.evaluate((node) => {
355
+ const rect = node.getBoundingClientRect();
356
+ return { text: node.textContent?.trim(), visible: rect.width > 0 && rect.height > 0 };
357
+ });
358
+ if (control.text === '继续' && control.visible) {
359
+ await button.click();
360
+ await page.waitForFunction(() => ![...document.querySelectorAll('button')].some(node => {
361
+ const rect = node.getBoundingClientRect();
362
+ return node.textContent?.trim() === '继续' && rect.width > 0 && rect.height > 0;
363
+ }), { timeout: UI_TIMEOUT_MS });
364
+ return;
365
+ }
366
+ }
367
+ };
368
+ await dismissDisclosure();
369
+ const workspaceTitle = basename(workspaceDir);
370
+ const workspaceRpc = await page.evaluate(async (path) => {
371
+ const rpcId = crypto.randomUUID();
372
+ const response = await fetch('/api/workspace/create', {
373
+ method: 'POST',
374
+ headers: { 'content-type': 'application/json' },
375
+ body: JSON.stringify({ type: 'client-request', rpcId, method: 'workspace/create', payload: { args: { request: { path } } } }),
376
+ });
377
+ return { status: response.status, body: await response.text() };
378
+ }, workspaceDir);
379
+ assert.equal(workspaceRpc.status, 200, `workspace RPC failed: ${workspaceRpc.status} ${workspaceRpc.body}`);
380
+ const workspaceResult = JSON.parse(workspaceRpc.body);
381
+ assert.equal(workspaceResult.result?.ok, true, `workspace RPC rejected: ${workspaceRpc.body}`);
382
+ await page.reload({ waitUntil: 'networkidle2', timeout: 30_000 });
383
+ await dismissDisclosure();
384
+ await page.waitForFunction((title) => document.body.innerText.includes(title), { timeout: UI_TIMEOUT_MS }, workspaceTitle);
385
+ await page.click('button[aria-label="选择工作区"], button[aria-label="Select workspace"]');
386
+ await page.waitForFunction((title) => [...document.querySelectorAll('[role="menuitem"], [role="option"], button')].some(node => node.textContent?.trim() === title), { timeout: UI_TIMEOUT_MS }, workspaceTitle);
387
+ await page.evaluate((title) => {
388
+ const node = [...document.querySelectorAll('[role="menuitem"], [role="option"], button')].find(candidate => candidate.textContent?.trim() === title);
389
+ node?.click();
390
+ }, workspaceTitle);
391
+ await page.waitForSelector('[contenteditable="true"]');
392
+ const submit = async (text) => {
393
+ const editor = await page.$('[contenteditable="true"]');
394
+ assert.ok(editor, 'real public composer missing');
395
+ await editor.click();
396
+ await page.keyboard.type(text);
397
+ await page.waitForFunction(() => [...document.querySelectorAll('button')].some(node => {
398
+ const label = node.getAttribute('aria-label');
399
+ return (label === '发送消息' || label === 'Send message') && !node.disabled;
400
+ }), { timeout: UI_TIMEOUT_MS });
401
+ const sendButtons = await page.$$('button[aria-label="发送消息"], button[aria-label="Send message"]');
402
+ let clicked = false;
403
+ for (const send of sendButtons) {
404
+ const control = await send.evaluate((node) => {
405
+ const button = node;
406
+ const rect = button.getBoundingClientRect();
407
+ return { disabled: button.disabled, visible: rect.width > 0 && rect.height > 0 };
408
+ });
409
+ if (!control.disabled && control.visible) {
410
+ await send.click();
411
+ clicked = true;
412
+ break;
413
+ }
414
+ }
415
+ assert.equal(clicked, true, 'real visible enabled composer send control missing');
416
+ const marker = text.match(/ISSUE289_SCENARIO_[AB]|ISSUE289_WARMUP/)?.[0];
417
+ if (marker)
418
+ await page.waitForFunction((target) => document.body.innerText.includes(target), { timeout: UI_TIMEOUT_MS }, marker);
419
+ };
420
+ const retryDom = async (marker) => page.evaluate((targetMarker, retryStateRowPattern) => {
421
+ const markerNode = [...document.querySelectorAll('*')].find(node => node.children.length === 0 && node.textContent?.includes(targetMarker));
422
+ const markerFlow = markerNode?.closest('[data-chat-turn]');
423
+ const turn = markerFlow?.getAttribute('data-chat-turn') || null;
424
+ const turnNodes = turn === null ? [] : [...document.querySelectorAll(`[data-chat-turn="${turn}"]`)];
425
+ const retryStateRow = new RegExp(retryStateRowPattern, 'i');
426
+ const statuses = turnNodes.flatMap(node => [...node.querySelectorAll('[role="status"]')].map(status => status.textContent?.trim() || ''));
427
+ const retryRows = turnNodes.flatMap(node => [...node.querySelectorAll('details')])
428
+ .filter(node => retryStateRow.test(node.textContent || ''))
429
+ .map(node => ({
430
+ text: node.textContent?.trim() || '',
431
+ active: node.hasAttribute('data-active'),
432
+ open: node.hasAttribute('open'),
433
+ visible: (() => { const visibleNode = node.querySelector('[role="status"]') || node.querySelector('summary') || node; const r = visibleNode.getBoundingClientRect(); return r.width > 0 && r.height > 0; })(),
434
+ }));
435
+ return { targetFound: markerNode !== undefined, turn, statuses, retryRows, turnText: turnNodes.map(node => node.textContent || '').join('\n'), bodyText: document.body.innerText };
436
+ }, marker, RETRY_STATE_ROW_PATTERN.source);
437
+ const snapshot = async (marker) => retryDom(marker);
438
+ const waitForTargetStatus = async (marker, pattern, description, active = false) => {
439
+ await page.waitForFunction((targetMarker, source, flags, requireActive) => {
440
+ const markerNode = [...document.querySelectorAll('*')].find(node => node.children.length === 0 && node.textContent?.includes(targetMarker));
441
+ const turn = markerNode?.closest('[data-chat-turn]')?.getAttribute('data-chat-turn');
442
+ if (turn === undefined)
443
+ return false;
444
+ const turnNodes = [...document.querySelectorAll(`[data-chat-turn="${turn}"]`)];
445
+ if (requireActive)
446
+ return turnNodes.flatMap(node => [...node.querySelectorAll('details[data-active]')]).some(row => {
447
+ const rect = row.getBoundingClientRect();
448
+ return rect.width > 0 && rect.height > 0 && [...row.querySelectorAll('[role="status"]')].some(node => new RegExp(source, flags).test(node.textContent || ''));
449
+ });
450
+ return turnNodes.flatMap(node => [...node.querySelectorAll('[role="status"]')]).some(node => new RegExp(source, flags).test(node.textContent || ''));
451
+ }, { timeout: UI_TIMEOUT_MS }, marker, pattern.source, pattern.flags, active);
452
+ return snapshot(marker);
453
+ };
454
+ const expandTargetProcess = async (marker) => page.evaluate((targetMarker) => {
455
+ const markerNode = [...document.querySelectorAll('*')].find(node => node.children.length === 0 && node.textContent?.includes(targetMarker));
456
+ const turn = markerNode?.closest('[data-chat-turn]')?.getAttribute('data-chat-turn');
457
+ if (turn === undefined)
458
+ return { found: false, clicked: false };
459
+ const processButton = [...document.querySelectorAll(`[data-chat-turn="${turn}"] button[data-turn-process]`)].find(node => node.getAttribute('aria-expanded') !== 'true');
460
+ if (!processButton)
461
+ return { found: true, clicked: false };
462
+ processButton.click();
463
+ return { found: true, clicked: true, ariaLabel: processButton.getAttribute('aria-label'), text: processButton.textContent?.trim() || '' };
464
+ }, marker);
465
+ const publicControls = async () => page.evaluate(() => ({
466
+ stopVisible: [...document.querySelectorAll('button[aria-label="停止生成"], button[aria-label="Stop generating"]')].some(node => {
467
+ const rect = node.getBoundingClientRect();
468
+ return rect.width > 0 && rect.height > 0;
469
+ }),
470
+ sendEnabled: [...document.querySelectorAll('button[aria-label="发送消息"], button[aria-label="Send message"]')].some(node => {
471
+ const button = node;
472
+ const rect = button.getBoundingClientRect();
473
+ return !button.disabled && rect.width > 0 && rect.height > 0;
474
+ }),
475
+ }));
476
+ const typeWithoutSending = async (text) => {
477
+ const editor = await page.$('[contenteditable="true"]');
478
+ assert.ok(editor, 'real public composer missing while proving idle');
479
+ await editor.click();
480
+ await page.keyboard.type(text);
481
+ await page.waitForFunction(() => [...document.querySelectorAll('button[aria-label="发送消息"], button[aria-label="Send message"]')].some(node => {
482
+ const button = node;
483
+ const rect = button.getBoundingClientRect();
484
+ return !button.disabled && rect.width > 0 && rect.height > 0;
485
+ }), { timeout: UI_TIMEOUT_MS });
486
+ return { text, sendEnabled: true, sent: false };
487
+ };
488
+ await submit('ISSUE289_WARMUP:初始化本地隔离 Web 会话。');
489
+ await page.waitForFunction(() => document.body.innerText.includes('ISSUE289_WARMUP_SUCCESS'), { timeout: UI_TIMEOUT_MS });
490
+ await submit('ISSUE289_SCENARIO_A:请用一句话确认 retry E2E。');
491
+ await waitUntil(() => relay.records.some(record => record.scenario === 'A' && record.status === 429), 10_000, 'scenario A relay 429');
492
+ const scheduledPattern = /等待重试模型请求|正在重试模型请求|Waiting to retry model request|Retrying model request/;
493
+ const scheduledA = await waitForTargetStatus('ISSUE289_SCENARIO_A', scheduledPattern, 'scenario A scheduled retry', true);
494
+ assert.ok(scheduledA.retryRows.some(row => row.active && row.visible && scheduledPattern.test(row.text)), 'scenario A active scheduled retry row is not visibly rendered');
495
+ const screenshotScheduledA = join(outputDir, 'scenario-a-retry-scheduled.png');
496
+ await page.screenshot({ path: screenshotScheduledA, fullPage: true });
497
+ await page.waitForFunction(() => document.body.innerText.includes('ISSUE289_SCENARIO_A_SUCCESS'), { timeout: UI_TIMEOUT_MS });
498
+ const expansionA = await expandTargetProcess('ISSUE289_SCENARIO_A');
499
+ assert.equal(expansionA.found, true, 'scenario A public process disclosure missing');
500
+ const successA = await snapshot('ISSUE289_SCENARIO_A');
501
+ assert.ok(successA.statuses.some(status => /已重试模型请求|Retried model request/.test(status)), 'scenario A retry-started status missing from target DOM');
502
+ assert.ok(successA.retryRows.some(row => row.visible && /已重试模型请求|Retried model request/.test(row.text)), 'scenario A retry-started row is not visibly rendered after public process expansion');
503
+ assert.ok(successA.bodyText.includes('ISSUE289_SCENARIO_A_SUCCESS'), 'scenario A terminal assistant success missing');
504
+ const aRecords = relay.records.filter(record => record.scenario === 'A');
505
+ assert.ok(aRecords.length >= 2, 'scenario A must include the initial 429 and a successful provider request');
506
+ assert.equal(aRecords[0]?.status, 429, 'scenario A first provider response must be 429');
507
+ assert.ok(aRecords.slice(1).some(record => record.status === 200), 'scenario A must include a successful provider response');
508
+ const screenshotSuccessA = join(outputDir, 'scenario-a-retry-started-success.png');
509
+ await page.screenshot({ path: screenshotSuccessA, fullPage: true });
510
+ const newSessionButton = await page.$('button[aria-label="新建会话"], button[aria-label="New session"]');
511
+ assert.ok(newSessionButton, 'real public New session control missing');
512
+ const newSessionLabel = await page.evaluate((button) => button.getAttribute('aria-label'), newSessionButton);
513
+ await newSessionButton.click();
514
+ await page.waitForFunction((marker) => !document.body.innerText.includes(marker) && document.querySelectorAll('[data-chat-turn]').length === 0, { timeout: UI_TIMEOUT_MS }, 'ISSUE289_SCENARIO_A');
515
+ await page.waitForSelector('[contenteditable="true"]');
516
+ await submit('ISSUE289_SCENARIO_B:开始一个可取消的 retry。');
517
+ const scheduledB = await waitForTargetStatus('ISSUE289_SCENARIO_B', scheduledPattern, 'scenario B scheduled retry', true);
518
+ assert.ok(scheduledB.retryRows.some(row => row.active && row.visible && scheduledPattern.test(row.text)), 'scenario B active scheduled retry row is not visibly rendered');
519
+ const bRecordsBeforeCancel = relay.records.filter(record => record.scenario === 'B');
520
+ assert.equal(bRecordsBeforeCancel.length, 1, 'scenario B must have exactly one primary request before cancellation');
521
+ assert.equal(bRecordsBeforeCancel[0]?.status, 429, 'scenario B first primary provider response must be 429');
522
+ const stopButtons = await page.$$('button[aria-label="停止生成"], button[aria-label="Stop generating"]');
523
+ let stopButton = null;
524
+ for (const candidate of stopButtons) {
525
+ const control = await candidate.evaluate((node) => {
526
+ const button = node;
527
+ const rect = button.getBoundingClientRect();
528
+ return { disabled: button.disabled, visible: rect.width > 0 && rect.height > 0 };
529
+ });
530
+ if (!control.disabled && control.visible) {
531
+ stopButton = candidate;
532
+ break;
533
+ }
534
+ }
535
+ assert.ok(stopButton, 'real visible enabled Stop control missing');
536
+ const stopControl = await page.evaluate((button) => {
537
+ const html = button;
538
+ const rect = html.getBoundingClientRect();
539
+ return { ariaLabel: html.getAttribute('aria-label'), disabled: html.disabled, visible: rect.width > 0 && rect.height > 0 };
540
+ }, stopButton);
541
+ assert.equal(stopControl.disabled, false, 'real public Stop control is disabled during retry backoff');
542
+ assert.equal(stopControl.visible, true, 'real public Stop control is not visible during retry backoff');
543
+ const bCountAtCancel = bRecordsBeforeCancel.length;
544
+ await stopButton.click();
545
+ const cancelObservedAt = Date.now();
546
+ const cancelledB = await waitForTargetStatus('ISSUE289_SCENARIO_B', /模型请求重试已取消|Model request retry cancelled/, 'scenario B cancelled retry');
547
+ assert.ok(cancelledB.retryRows.some(row => row.visible && !row.active && /模型请求重试已取消|Model request retry cancelled/.test(row.text)), 'scenario B cancelled retry row is not visibly rendered');
548
+ const cancelledControls = await publicControls();
549
+ assert.equal(cancelledControls.stopVisible, false, 'public Stop control remains visible after cancellation');
550
+ assert.equal(cancelledB.retryRows.some(row => row.active), false, 'scenario B target still has an active retry after cancellation');
551
+ assert.equal(/正在重试模型请求|Retrying model request|生成中|Generating|进行中|Running/.test(cancelledB.turnText), false, 'scenario B target still shows a generating/running state after cancellation');
552
+ const idleText = '仅输入不发送:验证 Web 会话已空闲。';
553
+ const bCountBeforeIdle = relay.records.filter(record => record.kind === 'primary' && record.scenario === 'B').length;
554
+ const idleInput = await typeWithoutSending(idleText);
555
+ const idleControls = await publicControls();
556
+ assert.equal(idleControls.stopVisible, false, 'public Stop control reappeared while proving idle');
557
+ assert.equal(idleControls.sendEnabled, true, 'real public Send control is not enabled for the unsent idle input');
558
+ assert.equal(relay.records.filter(record => record.kind === 'primary' && record.scenario === 'B').length, bCountBeforeIdle, 'typing idle input must not issue a B provider request');
559
+ await new Promise(resolve => setTimeout(resolve, CANCEL_DRAIN_MS));
560
+ const afterDrainB = await snapshot('ISSUE289_SCENARIO_B');
561
+ const afterDrainControls = await publicControls();
562
+ const bRecords = relay.records.filter(record => record.kind === 'primary' && record.scenario === 'B');
563
+ assert.equal(bRecords.length, bCountAtCancel, 'scenario B provider request count must not increase after UI cancellation');
564
+ assert.equal(bRecords.filter(record => Date.parse(record.at) >= cancelObservedAt).length, 0, 'scenario B must not issue a provider request after UI cancellation');
565
+ assert.ok(afterDrainB.retryRows.some(row => row.visible && !row.active && /模型请求重试已取消|Model request retry cancelled/.test(row.text)), 'scenario B cancelled retry row is not visibly rendered after drain');
566
+ assert.equal(afterDrainB.retryRows.some(row => row.active), false, 'scenario B target gained an active retry during drain');
567
+ assert.equal(afterDrainB.retryRows.some(row => /已重试模型请求|Retried model request/.test(row.text)), false, 'scenario B target gained retry-started after cancellation');
568
+ assert.equal(/正在重试模型请求|Retrying model request|生成中|Generating|进行中|Running/.test(afterDrainB.turnText), false, 'scenario B target shows a generating/running state after drain');
569
+ assert.equal(afterDrainControls.stopVisible, false, 'public Stop control is visible after cancellation drain');
570
+ assert.equal(afterDrainControls.sendEnabled, true, 'real public Send control is not enabled after cancellation drain');
571
+ assert.equal(relay.records.filter(record => record.kind === 'primary' && record.scenario === 'B').length, bCountBeforeIdle, 'idle input or drain changed the B primary baseline');
572
+ const screenshotCancelledB = join(outputDir, 'scenario-b-cancelled-idle.png');
573
+ await page.screenshot({ path: screenshotCancelledB, fullPage: true });
574
+ assertions.scenarioA = { scheduled: scheduledA, expansionA, started: successA, success: successA, providerRecords: aRecords };
575
+ assertions.sessions = { scenarioA: 'first public session', newSessionControl: { clicked: true, ariaLabel: newSessionLabel }, scenarioB: 'new public session after New session click' };
576
+ assertions.scenarioB = { scheduled: scheduledB, cancelled: cancelledB, controlsAfterCancel: cancelledControls, idleInput, idleControls, afterDrain: afterDrainB, afterDrainControls, cancelObservedAt: new Date(cancelObservedAt).toISOString(), requestCountAtCancel: bCountAtCancel, requestCountBeforeIdle: bCountBeforeIdle, providerRecords: bRecords };
577
+ assertions.relay = { allRecords: relay.records, scenarioARequests: aRecords.length, scenarioBRequestsAtCancel: bCountAtCancel, scenarioBRequestsAfterDrain: bRecords.length, cancelDrainMs: CANCEL_DRAIN_MS };
578
+ const finalSha = (await run('git', ['rev-parse', 'HEAD'], ROOT, process.env)).trim();
579
+ assert.equal(finalSha, baseSha, `HEAD changed during E2E: expected ${baseSha}, got ${finalSha}`);
580
+ assertions.finalSha = finalSha;
581
+ const domPath = join(outputDir, 'issue-289-web-retry-e2e.json');
582
+ writeFileSync(domPath, JSON.stringify(assertions, null, 2) + '\n', { mode: 0o600 });
583
+ const logPath = join(outputDir, 'issue-289-web-retry-e2e.log');
584
+ writeFileSync(logPath, redactSecrets(serverOutput), { mode: 0o600 });
585
+ const receipt = {
586
+ baseSha,
587
+ finalSha,
588
+ node: process.version,
589
+ screenshots: [screenshotScheduledA, screenshotSuccessA, screenshotCancelledB].map(path => ({ path, sha256: sha256(path) })),
590
+ assertions: { path: domPath, sha256: sha256(domPath) },
591
+ log: { path: logPath, sha256: sha256(logPath) },
592
+ relay: { scenarioARequests: aRecords.length, scenarioBRequestsAtCancel: bCountAtCancel, scenarioBRequestsBeforeIdle: bCountBeforeIdle, scenarioBRequestsAfterDrain: bRecords.length, noProviderRequestAfterCancel: true, noRetryStartedAfterCancel: true },
593
+ };
594
+ const receiptPath = join(outputDir, 'issue-289-web-retry-e2e.receipt.json');
595
+ writeFileSync(receiptPath, JSON.stringify(receipt, null, 2) + '\n', { mode: 0o600 });
596
+ passed = true;
597
+ console.log(`ISSUE #289 WEB RETRY/CANCEL E2E: PASS node=${process.version} baseSha=${baseSha} screenshots=${screenshotScheduledA},${screenshotSuccessA},${screenshotCancelledB} receipt=${receiptPath} receiptSha256=${sha256(receiptPath)}`);
598
+ }
599
+ finally {
600
+ if (page && !passed) {
601
+ try {
602
+ const failureHtml = join(outputDir, 'issue-289-web-retry-e2e.failure.html');
603
+ const failurePng = join(outputDir, 'issue-289-web-retry-e2e.failure.png');
604
+ const failureLog = join(outputDir, 'issue-289-web-retry-e2e.failure.log');
605
+ const failureSha = (await run('git', ['rev-parse', 'HEAD'], ROOT, process.env)).trim();
606
+ assert.equal(failureSha, baseSha, `HEAD changed during failed E2E: expected ${baseSha}, got ${failureSha}`);
607
+ writeFileSync(failureHtml, await page.content(), { mode: 0o600 });
608
+ await page.screenshot({ path: failurePng, fullPage: true });
609
+ writeFileSync(failureLog, redactSecrets(`baseSha=${baseSha}\nfinalSha=${failureSha}\nnode=${process.version}\n${serverOutput}\nrelayRecords=${JSON.stringify(relay.records, null, 2)}\n`), { mode: 0o600 });
610
+ console.error(`ISSUE #289 WEB RETRY/CANCEL E2E failure artifacts: html=${failureHtml}; screenshot=${failurePng}; log=${failureLog}`);
611
+ }
612
+ catch { /* browser may have failed before a document existed */ }
613
+ }
614
+ await browser?.close().catch(() => { });
615
+ const serverPid = server?.pid;
616
+ await terminateServer(server);
617
+ await relay.close().catch(() => { });
618
+ const remainingOwnedProcesses = await reapOwnedProcesses(serverPid, [consumerDir, userDataDir]);
619
+ if (remainingOwnedProcesses.length > 0) {
620
+ passed = false;
621
+ const cleanupLog = join(outputDir, 'issue-289-web-retry-e2e.cleanup-failure.log');
622
+ writeFileSync(cleanupLog, `ownedProcessIds=${JSON.stringify(remainingOwnedProcesses)}\n`, { mode: 0o600 });
623
+ throw new Error(`fixture cleanup left owned processes: ${remainingOwnedProcesses.join(',')}`);
624
+ }
625
+ for (const directory of [packageDir, consumerDir, workspaceDir, homeDir, dshHome, userDataDir]) {
626
+ rmSync(directory, { recursive: true, force: true });
627
+ }
628
+ }
629
+ }
630
+ await main();