@optima-chat/dev-skills 0.16.6 → 0.16.8

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 (40) hide show
  1. package/.claude/commands/logs.md +1 -1
  2. package/.claude/commands/query-db.md +6 -1
  3. package/.claude/skills/discount-codes/SKILL.md +4 -4
  4. package/.claude/skills/generate-test-token/SKILL.md +8 -4
  5. package/.claude/skills/logs/SKILL.md +5 -3
  6. package/.claude/skills/show-env/SKILL.md +1 -1
  7. package/.claude/skills/yzsgo-e2e/SKILL.md +19 -0
  8. package/.claude/skills/yzsgo-e2e/SYNC.md +26 -2
  9. package/.claude/skills/yzsgo-e2e/chat_driver.py +221 -47
  10. package/.claude/skills/yzsgo-e2e/pull_wire.py +39 -8
  11. package/.claude/skills/yzsgo-e2e/run_e2e.py +17 -2
  12. package/.codex/skills/generate-test-token/SKILL.md +3 -2
  13. package/.codex/skills/logs/SKILL.md +4 -2
  14. package/.codex/skills/show-env/SKILL.md +4 -2
  15. package/.codex/skills/yzsgo-e2e/SKILL.md +19 -0
  16. package/.codex/skills/yzsgo-e2e/SYNC.md +26 -2
  17. package/.codex/skills/yzsgo-e2e/chat_driver.py +221 -47
  18. package/.codex/skills/yzsgo-e2e/pull_wire.py +39 -8
  19. package/.codex/skills/yzsgo-e2e/run_e2e.py +17 -2
  20. package/AGENTS.md +1 -1
  21. package/bin/cli.js +3 -3
  22. package/bin/helpers/billing-http.ts +12 -3
  23. package/bin/helpers/db-utils.ts +8 -5
  24. package/bin/helpers/entitlement/grant.ts +5 -0
  25. package/bin/helpers/entitlement/revoke.ts +5 -0
  26. package/bin/helpers/generate-test-token.ts +40 -3
  27. package/bin/helpers/grant-credits.ts +8 -2
  28. package/bin/helpers/grant-subscription.ts +8 -4
  29. package/bin/helpers/operator.ts +9 -0
  30. package/bin/helpers/verify-health.ts +77 -29
  31. package/dist/bin/helpers/billing-http.js +9 -1
  32. package/dist/bin/helpers/db-utils.js +8 -2
  33. package/dist/bin/helpers/entitlement/grant.js +7 -0
  34. package/dist/bin/helpers/entitlement/revoke.js +7 -0
  35. package/dist/bin/helpers/generate-test-token.js +40 -3
  36. package/dist/bin/helpers/grant-credits.js +9 -2
  37. package/dist/bin/helpers/grant-subscription.js +9 -3
  38. package/dist/bin/helpers/operator.js +11 -0
  39. package/dist/bin/helpers/verify-health.js +83 -27
  40. package/package.json +1 -1
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: "logs"
3
- description: "Use when the user asks to inspect service logs, debug runtime failures, or compare CI, Stage, and Prod logs for Optima services."
3
+ description: "Use when the user asks to inspect service logs, debug runtime failures, or compare logs across CI, Stage, Prod, cn-prod, and cn-stage for Optima services."
4
4
  ---
5
5
 
6
6
  # Inspect Service Logs
@@ -14,6 +14,7 @@ Use the local shell and follow the environment-specific workflow:
14
14
  - `ci`: SSH to the shared CI host and read Docker Compose logs
15
15
  - `stage`: read AWS CloudWatch logs from `/ecs/<service>-stage`
16
16
  - `prod`: read AWS CloudWatch logs from `/ecs/<service>-prod` with `--region ap-southeast-1`
17
+ - `cn-prod` / `cn-stage`: Alibaba Cloud SAE (cn-beijing) — use `optima-logs <service> --env cn-prod|cn-stage`, which queries SLS directly (no buildbox hop)
17
18
 
18
19
  ## Common Services
19
20
 
@@ -29,7 +30,8 @@ Use the local shell and follow the environment-specific workflow:
29
30
 
30
31
  ## Guidance
31
32
 
32
- - Default to `ci` unless the user clearly requests `stage` or `prod`.
33
+ - Default to `ci` unless the user clearly requests another environment — note `ci` is the SSH + Docker Compose path, **not** `optima-logs`.
34
+ - 🔴 `optima-logs` with no `--env` defaults to **`cn-prod` (Alibaba Cloud production)**, not `ci` (see `bin/helpers/logs.ts`). Always pass `--env` explicitly, or you will be reading production logs while believing you are reading CI.
33
35
  - For `prod`, keep the query narrow and use the exact service the user asked for.
34
36
  - When investigating an error, read enough context around the failure instead of only grepping a single line.
35
37
 
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: "show-env"
3
- description: "Use when the user asks to inspect environment variables, service configuration, or Infisical-backed settings for Stage or Prod."
3
+ description: "Use when the user asks to inspect environment variables, service configuration, or Infisical-backed settings for Stage, Prod, cn-prod, or cn-stage."
4
4
  ---
5
5
 
6
6
  # Inspect Environment Configuration
@@ -10,7 +10,7 @@ Use this skill to inspect current shell environment variables or service configu
10
10
  ## Preferred Command
11
11
 
12
12
  ```bash
13
- optima-show-env <service> <stage|prod> [options]
13
+ optima-show-env <service> <stage|prod|cn-prod|cn-stage> [options]
14
14
  ```
15
15
 
16
16
  ## Common Options
@@ -18,6 +18,7 @@ optima-show-env <service> <stage|prod> [options]
18
18
  ```bash
19
19
  optima-show-env commerce-backend stage --filter DATABASE
20
20
  optima-show-env user-auth prod --keys-only
21
+ optima-show-env gateway-core cn-prod --filter REDIS
21
22
  ```
22
23
 
23
24
  ## Guidance
@@ -25,3 +26,4 @@ optima-show-env user-auth prod --keys-only
25
26
  - For local shell variables, simple shell commands like `env` or `echo $VAR` are enough.
26
27
  - For service configuration, prefer `optima-show-env` over raw Infisical API calls.
27
28
  - If the user only needs key names, use `--keys-only` to avoid exposing values unnecessarily.
29
+ - `cn-prod` / `cn-stage` read the separate Alibaba Cloud cn Infisical instance and need `INFISICAL_CN_EMAIL` / `INFISICAL_CN_PASSWORD` (or a `~/.infisical_cn_creds` file).
@@ -27,6 +27,25 @@ allowed-tools: ["Bash", "Read", "Write", "Agent", "Workflow"]
27
27
  - 每个 ✋ 缺项:引导用户——`chrome-9222`:`python3 $S/bootstrap.py launch-chrome` 起窗口、让用户**手动登测试账号**(登一次长期免登);`buildbox-pw`:让用户把口令放 `~/.buildbox_pw`(内部拉 wire 用,向团队要);`test-user-id`:让用户登录 Optima(run_e2e 自动从 `~/.optima/token.json` 读 userId)。
28
28
  - 补完 `python3 $S/preflight.py <env>` 复检,直到全 ✅ 才进四段。
29
29
 
30
+ ## 并发(2026-09-09 起):本 skill 自己开 tab
31
+
32
+ 鸭嘴兽支持并发任务了 —— **一个浏览器 tab = 一个独立 gateway session**(一 tab 一 session,
33
+ 跨 tab 可真并行,同 tab 内仍串行;上限按 plan:free 1 / starter 2 / pro 4 / enterprise 20)。
34
+
35
+ `chat_driver.attach()` **默认就自己开一个 tab**(`own_tab="auto"`)——平台既然支持多 tab,
36
+ 独占一个 session 就是常态,共用别人的 tab 才是例外。所以 `run_e2e.py` 直接 `attach()` 即可,
37
+ 不去抢用户已经开着的 chat tab。两个好处——① 不跟用户/别的跑测互相串台;② 拿到确定的 `sessionId`,
38
+ `locate_conversation(..., session_id=...)` 按它**确定性**定位 wire 对话,不再靠 (时间, 首句) 猜
39
+ (并发跑时多个会话时间戳交叠,启发式会挑错)。`meta.json` 里多了 `session_id` 字段。
40
+
41
+ 该环境 multi-tab flag 没开(`NEXT_PUBLIC_MULTI_TAB_SESSION`,build-time、**默认关**;
42
+ cn-prod 已验开、cn-stage 未验)时 `"auto"` 档自己会降级复用已有 tab(`d.tab_isolated=False`)
43
+ 并打 warn,功能不减,只是 wire 定位退回启发式。
44
+
45
+ 🔴 **绝不能让两个 driver 共用一个 tab**:实证会**静默串台**——两个线程写同一个 textarea,
46
+ 后写的覆盖先写的,只有一条消息真到服务端,两边却都抓到同一份回复,还全程无报错。
47
+ 细节见 `chat_driver.py` 模块 docstring。
48
+
30
49
  ## 四段流程
31
50
  1. **preflight + 自举**:见上「环境自举」,**全绿**才继续。
32
51
  2. **驱动 + 拉 wire + 备料**:`$VENV/bin/python $S/run_e2e.py --env <env> --message ... [--answer k=v] [--expect ...] --out <dir>` → 出 `prepped.md` + `meta.json`。🔑 用 **venv 的 python**(playwright 在那儿);`--user` 不给则自动读 `~/.optima/token.json`。
@@ -2,9 +2,33 @@
2
2
 
3
3
  | 本文件 | 上游 repo · 路径 | commit | 同步日期 |
4
4
  |---|---|---|---|
5
- | chat_driver.py | optima-store-skills · .claude/skills/operating-yzsgo-chat/chat_driver.py | 7a1685a | 2026-08-31 |
6
- | pull_wire.py | optima-store-skills · .claude/skills/pulling-yzsgo-session-wire/pull_wire.py | 7a1685a | 2026-08-31 |
5
+ | chat_driver.py | optima-store-skills · .claude/skills/operating-yzsgo-chat/chat_driver.py | 3cfc2d6 | 2026-09-09 |
6
+ | pull_wire.py | optima-store-skills · .claude/skills/pulling-yzsgo-session-wire/pull_wire.py | 3cfc2d6 | 2026-09-09 |
7
7
  | prep_conversation.py | optima-gateway · .claude/skills/conversation-iq/prep_session.py(改编:+浏览器证据合并) | b75f575c | 2026-08-31 |
8
8
  | judge_workflow.js | optima-gateway · .claude/skills/conversation-iq/workflow.js(改编:+前后端一致性维度) | b75f575c | 2026-08-31 |
9
9
 
10
10
  > 注:judge_workflow.js 内联的 decideOutcome 是 judge_outcome.js(有 node 单测)的副本,改逻辑需同步两处。
11
+
12
+ ## 2026-09-09 这次同步带了什么
13
+
14
+ 上游把 e2e 改成**并发跑**(鸭嘴兽支持并发任务:一个 tab = 一个独立 gateway session)。
15
+ `chat_driver.py` 逐字取自上游 `3cfc2d6`(optima-store-skills#206,已合入 main),本次变更点:
16
+
17
+ - `attach()` **默认自己开 tab** 独占 session(`own_tab="auto"`;拿不到独立 session 就降级复用
18
+ 已有 tab 并置 `tab_isolated=False`)。`own_tab=True` 是严格档,并行必用——auto 档多 worker
19
+ 一起降级会都落到同一个 tab 上 = 静默串台。
20
+ - `session_id` / `close()` 只关自己开的 tab / `concurrency_status()` 读并发名额。
21
+ - send 拒绝分流从三码扩到五码(新增 `concurrency_limit` / `busy_elsewhere`)。
22
+
23
+ `pull_wire.py` 是**改编**(不逐字)。这次做了两件事:
24
+
25
+ 1. **补回漏掉的上游修复 #102**(`bfa103c`「末 response 缺失显式标注,不再静默截断」)——
26
+ vendored 副本从 2026-08-31 起就没跟上,一直在静默截断。上游的 4 条回归也一并移植到
27
+ `tests/yzsgo-e2e/test_pull_wire_render.py`(另加一条锁 `render_conversation` 的 6 元 arity,
28
+ 因为 `run_e2e.py` 按 6 元解包,少一个会在出报告前一步崩)。
29
+ ⚠️ 教训:`verify_drift.py` 对改编文件只标「⚠️(改编·预期)」,**看到这个标记要真的去读上游有什么新提交**,
30
+ 不能当成「预期差异」放过——这次就是那么漏掉半个月的。
31
+ 2. 自行加了 `locate_conversation(..., session_id=...)`:
32
+ 按 gateway sessionId **确定性**定位本轮对话,取代 (时间, 首句) 启发式 —— 并发跑时多个会话
33
+ 时间戳交叠,启发式会挑错。传了 sid 却命不中就返回 None,**不静默回退**全局启发式
34
+ (回退会安静地定位到别的 session 的对话)。上游没有这个函数,是本仓独有的腿。
@@ -11,21 +11,51 @@
11
11
 
12
12
  用法:
13
13
  from chat_driver import ChatDriver
14
- d = ChatDriver().attach()
14
+ d = ChatDriver().attach() # 串行:复用页面上已有的 chat tab
15
+ d = ChatDriver().attach(own_tab=True) # 并行:**自己开一个 tab**,独占一个 gateway session
15
16
  d.ensure_installed(["briefing-store-status"])
16
17
  d.new_conversation()
17
18
  r = d.send_and_wait("跑一下老赵店的运营简报", timeout=300)
18
19
  print(r["text"], r["elapsed"], r["tool_trace"])
19
20
  d.close()
21
+
22
+ 🔴 并发形态(2026-09-09 真机坐实,别再按「同一时间只能一个对话」写代码):
23
+ 鸭嘴兽已支持并发任务 —— **一个浏览器 tab = 一个独立 gateway session**(agentic-chat ac#957,
24
+ sessionId 存 sessionStorage `optima:gw:sid`,天然 per-tab;F5 靠 sessionAttachProvider attach 回同一个)。
25
+ · 跨 tab = 可并行:实测两 tab 的 LLM 调用在服务端 wire 上真重叠约 3.0s。
26
+ · 同 tab 内 = 仍串行:一个 session 里另一个对话在跑会被 CONCURRENT_CONVERSATION_BLOCKED 拒。
27
+ · 并发上限按 plan 分档(free 1 / starter 2 / pro 4 / enterprise 20),超了 CONCURRENCY_LIMIT_EXCEEDED。
28
+ ⇒ 想并行跑 N 个用例,就开 N 个 tab、每 tab 一个 ChatDriver,**不是**在一个 tab 里开 N 个对话。
29
+
30
+ ⚠️ 两个 driver 落到**同一个 tab** 会静默串台(2026-09-09 负向对照实证):两个线程往同一个
31
+ textarea 写字,后写的覆盖先写的,**只有一条消息真到服务端**(wire 里那个 session 只有 1 个对话),
32
+ 但两边 send() 都返回 True、无 toast、无 console 报错,双方都抓到同一份回复 ——
33
+ A 用例被判在 B 的回复上,harness 任何信号都拦不住。所以并行时必须 `attach(own_tab=True)`。
20
34
  """
21
35
  from __future__ import annotations
22
36
 
37
+ import os
23
38
  import time
24
39
 
25
40
  from playwright.sync_api import sync_playwright
26
41
 
27
- CHAT_URL = "https://www.yzsgo.com/zh-HK/chat"
42
+ # cn-stage 前端是 app.stage.optima.chat(同一套页面),用 YZSGO_CHAT_URL 覆盖;缺省 cn-prod。
43
+ CHAT_URL = os.environ.get("YZSGO_CHAT_URL", "https://www.yzsgo.com/zh-HK/chat")
28
44
  SEARCH_PH = "搜尋技能..."
45
+ # 本 tab 认领的 gateway sessionId 存这里(agentic-chat src/lib/session/tabSessionClaim.ts)。
46
+ # sessionStorage 天然 per-tab —— 这是「一 tab 一 session」的物理依据,也是并行隔离的校验口。
47
+ TAB_SID_KEY = "optima:gw:sid"
48
+
49
+
50
+ class TabSessionUnavailable(RuntimeError):
51
+ """新开的 tab 没能拿到**独立**的 gateway session —— 并行不安全,调用方必须退回串行。
52
+
53
+ 两种触发(都实证过):
54
+ ① claim 超时:页面没在 timeout 内写 sessionStorage(没登录 / 连不上 gateway);
55
+ ② sid 与已有 tab 撞车:该环境 `NEXT_PUBLIC_MULTI_TAB_SESSION` 没开(build-time flag,
56
+ 默认关,见 agentic-chat src/lib/feature-flags.ts),新 tab 会被 gateway 的
57
+ createOrResolveSession 并回同一个 session。cn-prod 已开、cn-stage 未验。
58
+ 绝不能降级成「那就共用一个 tab 吧」——那正是静默串台的成因。"""
29
59
 
30
60
 
31
61
  class ChatDriver:
@@ -34,36 +64,134 @@ class ChatDriver:
34
64
  self._pw = None
35
65
  self.browser = None
36
66
  self.page = None
67
+ self.session_id = None # 本 tab 认领的 gateway sessionId(wire 归因/并行隔离校验都靠它)
68
+ self._own_tab = False # 这个 tab 是我开的吗 —— close() 只关自己开的,绝不关用户的
69
+ self.tab_isolated = False # 是否真独占了一个 gateway session(并行的前提,降级后为 False)
37
70
  self._tool_baseline = 0 # 发送前的「個工具」面板数;本轮只抓之后新增的(防超时用例污染下一轮)
38
71
  self._console_errs = [] # 前端 console 报错缓冲——区分「傳送失敗」的真实根因(weekly_limit vs credits)
39
72
 
40
73
  # ── 连接生命周期 ──
41
- def attach(self) -> "ChatDriver":
74
+ @staticmethod
75
+ def _read_sid(page):
76
+ """读某个 page 认领的 gateway sessionId(读不到返回 None)。"""
77
+ try:
78
+ return page.evaluate("(k)=>sessionStorage.getItem(k)", TAB_SID_KEY)
79
+ except Exception:
80
+ return None
81
+
82
+ def _await_sid(self, timeout: int = 60):
83
+ """等本 tab 把 sessionId 认领上(session_ready 才写)。超时返回 None。"""
84
+ deadline = time.time() + timeout
85
+ while time.time() < deadline:
86
+ sid = self._read_sid(self.page)
87
+ if sid:
88
+ return sid
89
+ self.page.wait_for_timeout(1000)
90
+ return None
91
+
92
+ def attach(self, own_tab="auto", claim_timeout: int = 60) -> "ChatDriver":
93
+ """连上调试端口 Chrome 并选定本 driver 要驱动的 tab。
94
+
95
+ `own_tab` 三态 —— **默认自己开 tab**(平台既然支持多 tab,独占就是常态,共用才是例外):
96
+
97
+ - `"auto"`(默认):先试着自己开 tab 独占一个 gateway session;该环境不支持多 tab
98
+ (`NEXT_PUBLIC_MULTI_TAB_SESSION` 没开,新 tab 会被并回同一个 session)或认领超时时,
99
+ **降级复用已有 tab** 并把 `self.tab_isolated` 置 False、打一行 warn。
100
+ 单 driver 场景下复用是安全的(那就是改并发之前的老行为),所以降级不是问题。
101
+ - `True`(**并行必用**):严格独占,拿不到独立 session 直接抛 `TabSessionUnavailable`。
102
+ 🔴 并行时绝不能用 "auto" —— 多个 worker 各自降级到同一个已有 tab = 静默串台
103
+ (见模块 docstring 的实证)。要并行就必须 fail-fast 让调用方退回串行。
104
+ - `False`:显式复用已有 tab(旧行为;只在你确实想操作用户当前那个 tab 时用)。
105
+
106
+ `self.session_id` 记认领到的 gateway sessionId(Wire 归因锚点);
107
+ `self.tab_isolated` 说明这个 driver 是不是真独占了一个 session。
108
+ """
109
+ strict = (own_tab is True)
110
+ want_own = (own_tab is True or own_tab == "auto")
42
111
  self._pw = sync_playwright().start()
43
112
  self.browser = self._pw.chromium.connect_over_cdp(f"http://localhost:{self.port}")
44
113
  ctx = self.browser.contexts[0]
45
- # 明确选 chat page(url 含 /chat),别抓到残留 tab(如 device 授权页)——它可能中途关闭致 TargetClosedError
46
- chat = [p for p in ctx.pages if "/chat" in p.url]
47
- self.page = chat[0] if chat else (ctx.pages[-1] if ctx.pages else ctx.new_page())
114
+ if want_own:
115
+ # 先快照**别的 tab 已认领的 sid**,再开自己的 —— 顺序反了就分不清撞没撞车。
116
+ # ⚠️ worker 并行 attach 时调用方要串行化这一段(各自认领完再放下一个),
117
+ # 否则两个新 tab 可能都在对方写 sessionStorage 之前完成快照,撞车检测漏判。
118
+ taken = {sid for sid in (self._read_sid(p) for p in ctx.pages if "/chat" in p.url) if sid}
119
+ self.page = ctx.new_page()
120
+ self._own_tab = True
121
+ self.page.goto(CHAT_URL, wait_until="domcontentloaded")
122
+ else:
123
+ # 明确选 chat page(url 含 /chat),别抓到残留 tab(如 device 授权页)——它可能中途关闭致 TargetClosedError
124
+ chat = [p for p in ctx.pages if "/chat" in p.url]
125
+ self.page = chat[0] if chat else (ctx.pages[-1] if ctx.pages else ctx.new_page())
126
+ taken = None
127
+ # 清掉可能残留的视口仿真:driver 进程被杀时 Playwright 的 1440x900 override 会孤儿化留在
128
+ # 标签页上(用户看到页面右侧大块空白)。别的会话 clear 不掉它——必须先 set 接管再 clear。
129
+ try:
130
+ cdp = self.page.context.new_cdp_session(self.page)
131
+ cdp.send("Emulation.setDeviceMetricsOverride",
132
+ {"width": 0, "height": 0, "deviceScaleFactor": 0, "mobile": False})
133
+ cdp.send("Emulation.clearDeviceMetricsOverride")
134
+ except Exception:
135
+ pass # 清不掉不影响驱动,只影响观感
48
136
  # 捕获前端 console:发送被拒时「傳送失敗」toast 是通用的,真实原因(weekly_limit=本周额度用完 / 积分不足 …)
49
137
  # 只在 console 里([Chat Error] weekly_limit…)。留最近 20 条供 send() 分流,别再把 weekly_limit 误报成 credits。
138
+ self._hook_console()
139
+ self.session_id = self._await_sid(claim_timeout if want_own else 10)
140
+ if want_own:
141
+ why = None
142
+ if not self.session_id:
143
+ why = f"新 tab {claim_timeout}s 内没认领到 gateway session(没登录?连不上 gateway?)"
144
+ elif self.session_id in taken:
145
+ why = (f"新 tab 与已有 tab 撞同一个 session({self.session_id})—— 该环境 multi-tab 没开"
146
+ f"(NEXT_PUBLIC_MULTI_TAB_SESSION 是 build-time flag、默认关)")
147
+ if why:
148
+ if strict:
149
+ self.close() # 失败路径也要把刚开的 tab 关掉,别留垃圾
150
+ raise TabSessionUnavailable(why + ",并行会静默串台。退回串行跑。")
151
+ # auto:降级复用已有 tab。单 driver 复用是安全的(= 改并发之前的老行为)。
152
+ print(f"[chat_driver] ⚠️ 独占 tab 失败({why})—— 降级复用已有 tab;"
153
+ f"**此 driver 不可用于并行**")
154
+ self._close_own_tab()
155
+ self._own_tab = False
156
+ chat = [p for p in ctx.pages if "/chat" in p.url]
157
+ self.page = chat[0] if chat else (ctx.pages[-1] if ctx.pages else ctx.new_page())
158
+ self._hook_console()
159
+ self.session_id = self._await_sid(10)
160
+ self.tab_isolated = False
161
+ return self
162
+ self.tab_isolated = bool(want_own)
163
+ return self
164
+
165
+ def _hook_console(self) -> None:
166
+ """捕获前端 console:发送被拒时「傳送失敗」toast 是通用的,真实原因(weekly_limit / 积分…)只在 console 里。"""
50
167
  self.page.on("console", lambda m: self._console_errs.append((m.text or "")[:200])
51
168
  if m.type in ("error", "warning") else None)
52
- return self
169
+
170
+ def _close_own_tab(self) -> None:
171
+ """只关本 driver 自己开的 tab(降级/收尾共用)。"""
172
+ if self._own_tab and self.page:
173
+ try:
174
+ self.page.close()
175
+ except Exception:
176
+ pass
53
177
 
54
178
  def close(self) -> None:
55
- # 只断开 attach,不关用户的 Chrome
179
+ # 只断开 attach,不关用户的 Chrome。**自己开的 tab 要自己关**(关掉即释放该 gateway
180
+ # session 的并发名额);不是自己开的(默认 attach 复用的用户 tab)一律不动。
56
181
  try:
57
- if self.browser:
58
- self.browser.close()
182
+ self._close_own_tab()
59
183
  finally:
60
- if self._pw:
61
- self._pw.stop()
184
+ try:
185
+ if self.browser:
186
+ self.browser.close()
187
+ finally:
188
+ if self._pw:
189
+ self._pw.stop()
62
190
 
63
191
  def _eval(self, js: str, arg=None):
64
192
  return self.page.evaluate(js, arg) if arg is not None else self.page.evaluate(js)
65
193
 
66
- # ── 单对话隔离(yzsgo 同一时间只能有一个对话在进行)──
194
+ # ── 单对话隔离(**本 tab 内**同一时间只能有一个对话在进行;跨 tab 可并行,见模块 docstring)──
67
195
  def is_generating(self) -> bool:
68
196
  """当前是否有对话正在流式生成。派生自 chat_state(单一真相)。"""
69
197
  return self.chat_state() == "generating"
@@ -169,10 +297,6 @@ class ChatDriver:
169
297
  return a.get("answer")
170
298
  return None
171
299
 
172
- def is_service_error(self) -> bool:
173
- """yzsgo 侧 LLM 服务报错(「AI 服務出錯」/llm_error toast)—— 非 skill 缺陷。"""
174
- return self.chat_state() == "service_error"
175
-
176
300
  def read_toasts(self) -> list:
177
301
  """读**全局 ToastContainer**(providers.tsx 挂载的 div[aria-live="assertive"],
178
302
  ui/Toast.tsx 渲染)里当前可见的 toast。返回 [{type,title,description}],
@@ -191,6 +315,10 @@ class ChatDriver:
191
315
  });
192
316
  }""") or []
193
317
 
318
+ def is_service_error(self) -> bool:
319
+ """yzsgo 侧 LLM 服务报错(「AI 服務出錯」/llm_error toast)—— 非 skill 缺陷。"""
320
+ return self.chat_state() == "service_error"
321
+
194
322
  def chat_state(self) -> str:
195
323
  """**统一感知对话 UI 状态**(单一真相),优先级:待输入 > 生成中 > 报错 > 空闲。
196
324
  所有 send/answer/wait 都据此判断,不靠「填了字就以为发出去了」。返回:
@@ -212,11 +340,6 @@ class ChatDriver:
212
340
  if([...document.querySelectorAll('[data-testid="stop-button"]')].some(vis)) return 'generating';
213
341
  // ③ 报错 toast:仅在**既不待输入也不生成**时才当真(自身文本短=真 toast)
214
342
  if([...document.querySelectorAll('*')].some(e=>{const t=(e.textContent||'').trim();return t.length<40 && /服務出錯,請重試|服务出错,请重试|AI 服務出錯|AI 服务出错/.test(t) && vis(e);})) return 'service_error';
215
- // ③b 全局 ToastContainer(providers.tsx 的 div[aria-live=assertive])里的 **error 级** toast:
216
- // useFriendlyError 走这条路(如 未找到/SESSION_NOT_FOUND、伺服器錯誤…),旧检查只认「AI 服務出錯」
217
- // 文案,这类 toast 完全感知不到(2026-08-31 用户截图实证盲区)。warning 级(重試类)不翻状态。
218
- const tc=document.querySelector('div[aria-live="assertive"]');
219
- if(tc && [...tc.querySelectorAll('div.rounded-lg')].some(e=>vis(e)&&/bg-red-50/.test(e.className||''))) return 'service_error';
220
343
  // ④ 输入框 disabled = 忙(生成中/上一轮 finalizing,但没显示 stop-button)→ 不算 idle,
221
344
  // 否则 send 会往禁用框打字+回车、无声失败(send_failed)。
222
345
  const ta=document.querySelector('textarea');
@@ -225,9 +348,11 @@ class ChatDriver:
225
348
  }""") or "idle"
226
349
 
227
350
  def ensure_idle(self, timeout: int = 90) -> bool:
228
- """让对话回到 idle 好开新的。**所有对话都是本 driver 发起、我控制它——正在生成就直接
229
- stop(abort),不被动等它自然结束**(用户点醒:我发起的我停,不用干等)。
230
- 待输入→关问题框;报错→reload 清 toast。返回是否 idle。"""
351
+ """让**本 tab** 回到 idle 好开新的。**本 tab 的对话都是本 driver 发起、我控制它——正在生成
352
+ 就直接 stop(abort),不被动等它自然结束**(用户点醒:我发起的我停,不用干等)。
353
+ 待输入→关问题框;报错→reload 清 toast。返回是否 idle。
354
+ ⚠️ 作用域**只有本 tab**:并行跑时别指望它能清别的 tab,也绝不会误停别的 tab 的 turn
355
+ (chat_state/stop_generating 都只看 self.page 的 DOM)。"""
231
356
  start = time.time()
232
357
  while time.time() - start < timeout:
233
358
  st = self.chat_state()
@@ -282,6 +407,26 @@ class ChatDriver:
282
407
  }""", name)
283
408
  self.page.wait_for_timeout(2000)
284
409
 
410
+ def concurrency_status(self) -> dict | None:
411
+ """读 gateway 的并发名额 `GET /api/sessions/concurrency` → {active, limit}。
412
+ 并行跑测**定标并行度**用:limit 是按 plan 的会话数上限(free 1 / starter 2 / pro 4 /
413
+ enterprise 20 / custom 不限);`limit=None` = 不执法(无权益/billing 不可达),
414
+ **不等于无限**,此时别贪,按保守值走。借页面自己的 token 发请求,不额外要凭据。
415
+ 读不到返回 None(网络/未登录/端点变更),调用方按「未知」处理、别当 0。"""
416
+ return self._eval(r"""async ()=>{
417
+ try{
418
+ const t=localStorage.getItem('unified_access_token'); if(!t) return null;
419
+ const base=[...new Set(performance.getEntriesByType('resource')
420
+ .map(e=>{try{return new URL(e.name).origin}catch(_){return ''}}))]
421
+ .find(o=>/(^|\/\/)(gw|gateway)\./.test(o));
422
+ if(!base) return null;
423
+ const r=await fetch(base+'/api/sessions/concurrency',{headers:{Authorization:'Bearer '+t}});
424
+ if(!r.ok) return null;
425
+ const j=await r.json();
426
+ return (typeof j.active==='number') ? {active:j.active, limit:j.limit} : null;
427
+ }catch(_){ return null; }
428
+ }""")
429
+
285
430
  def preflight(self, timeout: int = 150) -> dict:
286
431
  """测试前置预检(每轮必做):确认桌面客户端连着**正确账号**、能列出可操作的紫鸟店。
287
432
  桌面默认连的可能是别的账号 → 连店类 skill 会「桌面应用未连接」。返回 {ok, stores, reply}。"""
@@ -289,14 +434,17 @@ class ChatDriver:
289
434
  self.new_conversation()
290
435
  r = self.send_and_wait("先别执行任何店铺操作。请确认桌面客户端连接是否正常,并列出现在能操作的 TikTok 紫鸟店铺(Profile ID + 店铺名)。", timeout)
291
436
  txt = r["text"]
292
- stores = __import__("re").findall(r"(27\d{11,12})\s*[\t| ]*([^\n\t|]{0,30}货盘)", txt)
437
+ # 店名别写死后缀(旧账号店名带「货盘」,换账号就不带了):ID 后面跟到行尾/分隔符的即店名
438
+ stores = __import__("re").findall(r"(27\d{11,12})\s*[\t|::、 ]*([^\n\t|]{2,40})", txt)
293
439
  ok = bool(stores) # 能列出可操作店 = 桌面连着且账号对(Agent 回复可能顺带提「未连接」作说明,不据此判)
294
440
  return {"ok": ok, "stores": stores, "reply": txt[:600]}
295
441
 
296
442
  def new_conversation(self) -> bool:
297
443
  """开新对话,隔离每个测试用例。开新对话是**图标按钮**(文本空、靠 aria-label「新建對話」),
298
444
  不能用文本匹配。找不到则留在当前对话,返回 False。
299
- ⚠️ yzsgo 同一时间只能一个对话在跑 —— 上一个还在生成时开新对话会失败/串数据,先 ensure_idle。"""
445
+ ⚠️ **同一个 tab(=同一个 gateway session)里**同一时间只能一个对话在跑 —— 上一个还在生成时
446
+ 开新对话会失败/串数据,先 ensure_idle。想真并行请开多个 tab(attach(own_tab=True)),
447
+ 不是在这一个 tab 里连开对话。"""
300
448
  self.ensure_idle()
301
449
  click_new = r"""()=>{
302
450
  const el=[...document.querySelectorAll('button,a,[role=button]')].find(e=>{
@@ -307,10 +455,23 @@ class ChatDriver:
307
455
  if(!el) return false; el.click(); return true;
308
456
  }"""
309
457
  ok = self._eval(click_new)
458
+ if not ok:
459
+ # 「新建對話」按钮找不到 ≠ 按钮没了——技能市场等视图与聊天页**共用 /chat URL**,
460
+ # goto_chat() 会 no-op 停在别的视图(按钮 width=0)。切回「AI 助手」tab 再试一次。
461
+ self.goto_tab("AI 助手")
462
+ self.ensure_idle()
463
+ ok = self._eval(click_new)
310
464
  if ok:
311
465
  self.page.wait_for_timeout(1500)
312
- # 校验真空白:新对话不该残留「已完成 N 個工具」面板;残留=没真开新对话(上轮卡住)→ reload 兜底
313
- fresh = self._eval(r"""()=>![...document.querySelectorAll('button[aria-expanded]')].some(e=>/個工具|个工具/.test(e.textContent||''))""")
466
+ # 校验真空白:新对话既不该残留「已完成 N 個工具」面板,**也不该残留 AskUserQuestion 问答卡**。
467
+ # ⚠️ 漏了问答卡这条,就是 gmvmax 用例抓到上一条 promotions 残留问答卡(「建一场秒杀活动…」待输入)
468
+ # 的根因——工具面板碰巧空了就被当「真空白」放行,旧问答卡却还盖在页上。两样都空才算真新建成功。
469
+ # (鸭嘴兽 chat 页只有这两类残留会串下一条;这里的“框”指 question-card,不是卖家后台的风控验证码。)
470
+ fresh = self._eval(r"""()=>{
471
+ const hasTool=[...document.querySelectorAll('button[aria-expanded]')].some(e=>/個工具|个工具/.test(e.textContent||''));
472
+ const hasCard=[...document.querySelectorAll('[data-testid="question-card"]')].some(e=>{const r=e.getBoundingClientRect();return r.width>0&&r.height>0;});
473
+ return !hasTool && !hasCard;
474
+ }""")
314
475
  if not fresh:
315
476
  self.page.goto(CHAT_URL, wait_until="domcontentloaded")
316
477
  self.page.wait_for_timeout(5000)
@@ -327,7 +488,7 @@ class ChatDriver:
327
488
  def send(self, msg: str) -> bool:
328
489
  """发消息,并**验证真的发出去了**(状态离开 idle → generating/waiting,或输入框清空+消息上屏)。
329
490
  发不出去(仍 idle 且没上屏)返回 False —— 不再「填了字就以为发出去了」。
330
- ⚠️ **发之前强制 ensure_idle**:所有对话都是本 driver 发起的,上一个 turn 没结束就发下一个 =
491
+ ⚠️ **发之前强制 ensure_idle**:本 tab 的对话都是本 driver 发起的,上一个 turn 没结束就发下一个 =
331
492
  往忙着的对话里塞消息 → concurrent/傳送中卡死。从代码上根绝——绝不往非 idle 的对话发。"""
332
493
  if not self.ensure_idle(180):
333
494
  self._last_send_fail = "concurrent" # 上一个 turn 迟迟不结束 → 别硬发
@@ -352,25 +513,40 @@ class ChatDriver:
352
513
  if st == "service_error":
353
514
  self._last_send_fail = "service_error"
354
515
  return False
355
- # 消息被拒的两种根因,UI 都不进生成但含义不同,必须区分(否则 judge 报错方向全反):
356
- # ① 并发锁未释放:前一 turn 的 activeConversationId 锁没释放(另一對話正在處理/會話正忙…)→ 'concurrent'
357
- # 「傳送失敗/传送失败」:通用发送被拒,真实根因看 console(toast 本身分不出):
516
+ # 消息被拒的**五种**根因,UI 都不进生成但含义天差地别,必须区分(否则 judge 报错方向全反):
517
+ # ① 'concurrency_limit':会话数撞 plan 上限(ConcurrencyLimitNotice,testid
518
+ # concurrency-limit-notice)。并行跑测的头号 blocked —— 降并行度或升 plan,
519
+ # 跟积分/额度都无关。plan 分档:free 1 / starter 2 / pro 4 / enterprise 20。
520
+ # ② 'busy_elsewhere':**这个对话**正被别的 session(另一个 tab)处理
521
+ # (ConversationBusyNotice,testid conversation-busy-notice;文案「傳送失敗(另一會話處理中)」)。
522
+ # 🔴 它的文案里**也含「傳送失敗」**——必须排在 ④ 前面判,否则会被误报成积分不足。
523
+ # ③ 'concurrent':**同一个 session 内**前一个对话还没跑完(另一對話正在處理/會話正忙)。
524
+ # 注意 ② 和 ③ 不是一回事:② 跨 session,③ 同 session。
525
+ # ④ 「傳送失敗/传送失败」:通用发送被拒,真实根因看 console(toast 本身分不出):
358
526
  # [Chat Error] weekly_limit(本周额度用完)→ 'weekly_limit'(要升级 plan/等重置,充积分没用!)
359
527
  # 否则按积分耗尽(余额不足发不出)→ 'credits'
360
- # 收集 text='' 的失败也能被 judge 分流(升级 plan vs 充值 vs 等锁)。
528
+ # 收集 text='' 的失败也能被 judge 分流(降并行 vs 换 tab vs 等锁 vs 升级 plan vs 充值)。
529
+ # ①② 用前端**稳定 testid**认(agentic-chat ConcurrencyLimitNotice.tsx:46 /
530
+ # ConversationBusyNotice.tsx:32),比文案可靠;③④ 才退回短文本匹配。
361
531
  rej = self._eval(r"""()=>{
362
532
  const vis=e=>{const r=e.getBoundingClientRect();return r.width>0&&r.height>0;};
533
+ const seen=id=>[...document.querySelectorAll('[data-testid="'+id+'"]')].some(vis);
534
+ if(seen('concurrency-limit-notice')) return 'concurrency_limit';
535
+ if(seen('conversation-busy-notice')) return 'busy_elsewhere';
363
536
  const hit=[...document.querySelectorAll('*')].find(e=>{const t=(e.textContent||'').trim();
364
537
  return t.length<30 && /另一對話正在處理|另一对话正在处理|會話正忙|会话正忙|請稍後重試|请稍后重试|傳送失敗|传送失败/.test(t) && vis(e);});
365
538
  if(!hit) return '';
366
539
  const t=(hit.textContent||'').trim();
540
+ // 「傳送失敗(另一會話處理中)」= 跨 session 忙,别当积分不足
541
+ if(/另一會話處理中|另一会话处理中|另一個分頁|另一个标签页/.test(t)) return 'busy_elsewhere';
367
542
  return /傳送失敗|传送失败/.test(t) ? 'credits' : 'concurrent';
368
543
  }""")
369
544
  if rej == "credits" and any(("weekly_limit" in e or "本周额度" in e or "本週額度" in e)
370
545
  for e in self._console_errs):
371
546
  rej = "weekly_limit" # console 坐实:不是积分,是本周额度墙(升级 plan/等重置)
372
547
  if rej:
373
- self._last_send_fail = rej # weekly_limit / credits / concurrent
548
+ # concurrency_limit / busy_elsewhere / weekly_limit / credits / concurrent
549
+ self._last_send_fail = rej
374
550
  return False
375
551
  # 「傳送中」= 消息在途(还没被后端接受进生成),继续等,别当已发
376
552
  if self._eval(r"""()=>{const b=document.body.innerText||'';return b.includes('傳送中')||b.includes('传送中');}"""):
@@ -435,9 +611,6 @@ class ChatDriver:
435
611
  "timed_out": end_state == "timeout",
436
612
  "state": end_state,
437
613
  "tool_trace": self._scrape_tool_trace(),
438
- # 全局 toast 快照(错误码根因,如「錯誤代碼:SESSION_NOT_FOUND」)——service_error/timeout
439
- # 时判 blocked 根因用;正常 done 时多为空(非 persist toast 5s 自动消失)。
440
- "toasts": self.read_toasts(),
441
614
  }
442
615
 
443
616
  def send_and_wait(self, msg: str, timeout: int = 180, answers=None) -> dict:
@@ -446,7 +619,7 @@ class ChatDriver:
446
619
  # state 区分 credits(积分不足)/ concurrent(前一 turn 未释放)/ service_error / send_failed,judge 据此报清楚。
447
620
  return {"text": "", "transcript": "", "tail": "", "elapsed": 0,
448
621
  "timed_out": False, "state": getattr(self, "_last_send_fail", None) or "send_failed",
449
- "tool_trace": dict(self._EMPTY_TRACE), "toasts": self.read_toasts()}
622
+ "tool_trace": dict(self._EMPTY_TRACE)}
450
623
  return self.wait_reply(timeout, answers)
451
624
 
452
625
  def stop_generating(self) -> None:
@@ -475,11 +648,11 @@ class ChatDriver:
475
648
  const bs=[...document.querySelectorAll('button[aria-expanded]')].filter(e=>/個工具|个工具/.test(e.textContent||'')&&!e.hasAttribute('data-old'));
476
649
  const last=bs[bs.length-1]; if(!last) return [];
477
650
  const panel=last.parentElement; // 限定到当轮面板
478
- const ops=[...panel.querySelectorAll('div')].filter(e=>e.querySelectorAll('button').length===2 && /參數/.test(e.textContent) && /結果/.test(e.textContent) && e.textContent.trim().length<12);
651
+ const ops=[...panel.querySelectorAll('div')].filter(e=>e.querySelectorAll('button').length===2 && /參數|参数/.test(e.textContent) && /結果|结果/.test(e.textContent) && e.textContent.trim().length<12);
479
652
  const out=[];
480
653
  for(const op of ops){
481
654
  const t=(op.parentElement?.innerText||'').replace(/\n/g,' ').trim();
482
- if((t.match(/參數/g)||[]).length!==1) continue; // 跳过嵌套整面板行
655
+ if((t.match(/參數|参数/g)||[]).length!==1) continue; // 跳过嵌套整面板行
483
656
  const m=t.match(/^(\S+)\s+(已完成|失敗|失败|進行中|进行中|錯誤|错误|error|失敗了)/i);
484
657
  if(m) out.push({name:m[1], status:m[2]});
485
658
  }
@@ -527,8 +700,8 @@ class ChatDriver:
527
700
  def read_tool_io(self, index: int) -> dict:
528
701
  """深挖第 index 个 tool 的參數/結果内容(点开对应「參數」「結果」button 读文本)。判定存疑时用。"""
529
702
  return self._eval(r"""(idx)=>{
530
- const ops=[...document.querySelectorAll('div')].filter(e=>e.querySelectorAll('button').length===2 && /參數/.test(e.textContent) && /結果/.test(e.textContent) && e.textContent.trim().length<12);
531
- const clean=ops.filter(op=>((op.parentElement?.innerText||'').match(/參數/g)||[]).length===1);
703
+ const ops=[...document.querySelectorAll('div')].filter(e=>e.querySelectorAll('button').length===2 && /參數|参数/.test(e.textContent) && /結果|结果/.test(e.textContent) && e.textContent.trim().length<12);
704
+ const clean=ops.filter(op=>((op.parentElement?.innerText||'').match(/參數|参数/g)||[]).length===1);
532
705
  const op=clean[idx]; if(!op) return {err:'no-tool'};
533
706
  const btns=[...op.querySelectorAll('button')];
534
707
  btns.forEach(b=>b.click());
@@ -547,7 +720,7 @@ class ChatDriver:
547
720
  for(const n of nodes){
548
721
  let box=n; for(let i=0;i<5 && box;i++){ box=box.parentElement;
549
722
  if(!box) break;
550
- const p=[...box.querySelectorAll('button')].find(b=>b.textContent.trim()==='參數' && b.getBoundingClientRect().width>0);
723
+ const p=[...box.querySelectorAll('button')].find(b=>['參數','参数'].includes(b.textContent.trim()) && b.getBoundingClientRect().width>0);
551
724
  if(p){ p.click(); break; }
552
725
  }
553
726
  }
@@ -595,12 +768,13 @@ class ChatDriver:
595
768
  self.page.wait_for_timeout(2200)
596
769
 
597
770
  def card_status(self, slug: str) -> str:
598
- """搜到卡后读该 slug 卡的状态:已安裝 / 待安裝 / 安裝中 / notfound。"""
771
+ """搜到卡后读该 slug 卡的状态:已安裝 / 待安裝 / 安裝中 / notfound。
772
+ ⚠️ UI 文案随账号 locale 简繁都可能出现(zh-HK「安裝」/ zh-CN「安装」),两种都要匹配。"""
599
773
  return self._eval(r"""(slug)=>{
600
774
  const body=document.body.innerText||''; const i=body.indexOf(slug);
601
775
  if(i<0) return 'notfound';
602
776
  const seg=body.slice(i, i+60);
603
- return /已安裝/.test(seg)?'已安裝':/安裝中/.test(seg)?'安裝中':/安裝/.test(seg)?'待安裝':'?';
777
+ return /已安[裝装]/.test(seg)?'已安裝':/安[裝装]中/.test(seg)?'安裝中':/安[裝装]/.test(seg)?'待安裝':'?';
604
778
  }""", slug)
605
779
 
606
780
  def search_skill(self, slug: str) -> str:
@@ -616,7 +790,7 @@ class ChatDriver:
616
790
  return True
617
791
  if st == 'notfound':
618
792
  return False
619
- self._eval(r"""()=>{const btn=[...document.querySelectorAll('button')].find(e=>e.textContent.trim()==='安裝' && e.getBoundingClientRect().width>0 && !e.closest('nav'));if(btn)btn.click();}""")
793
+ self._eval(r"""()=>{const btn=[...document.querySelectorAll('button')].find(e=>/^安[裝装]$/.test(e.textContent.trim()) && e.getBoundingClientRect().width>0 && !e.closest('nav'));if(btn)btn.click();}""")
620
794
  self.page.wait_for_timeout(2500)
621
795
  # 可能的确认弹窗(pilot 实测无,但兜底)
622
796
  self._eval(r"""()=>{const b=[...document.querySelectorAll('button,[role=button]')].find(e=>/確認|确认|確定|确定|立即安/.test(e.textContent.trim()) && e.getBoundingClientRect().width>0);if(b)b.click();}""")