@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
@@ -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();}""")
@@ -169,6 +169,19 @@ def render_conversation(conv, resps, deref, idx):
169
169
  res_ids.add(b.get("tool_use_id"))
170
170
  dangling = [(u, n) for u, n in use_ids.items() if u not in res_ids]
171
171
 
172
+ # 末 response 状态:审查靠它判「最终回复能不能核」。缺失时**必须显式标注**,
173
+ # 否则 transcript 静默停在末条 user 消息(常是超长 tool_result 截断处),
174
+ # 会被误读成「响应被截断吞了」(上游 #102 真根因:不是截断,是末 req 无 response 记录)。
175
+ lastresp = resps.get(last["callId"])
176
+ if not lastresp:
177
+ last_state = "⚠️ 末 request 无 response 记录(生成中断/未落盘/末轮是 continuation)——最终回复不可核"
178
+ elif lastresp.get("kind") == "error":
179
+ last_state = "⚠️ 末 response 是 error:" + json.dumps(deref(lastresp.get("error") or {}), ensure_ascii=False)[:160]
180
+ elif (lastresp.get("finalMessage") or {}).get("stopReason") == "max_tokens":
181
+ last_state = "⚠️ 末 response stop_reason=max_tokens(回复被截断,可能未说完)"
182
+ else:
183
+ last_state = "有(正常 response)"
184
+
172
185
  L = [f"# 对话 #{idx} {ts0}",
173
186
  "", f"**prompt**: {prompt[:200]}", "",
174
187
  "## 事实卡(代码算的确定信息)",
@@ -176,9 +189,9 @@ def render_conversation(conv, resps, deref, idx):
176
189
  f"- error 响应 {len(errs)}" + (f":{[json.dumps(deref(e.get('error') or {}),ensure_ascii=False)[:120] for e in errs]}" if errs else ""),
177
190
  f"- stop_reason=max_tokens 的响应 {maxtok}",
178
191
  f"- 悬空 tool_use(无匹配 result){len(dangling)}: {dangling[:5]}",
192
+ f"- 末 response: {last_state}",
179
193
  "", "## 完整 transcript(末 req 全历史 + 末 response)", ""]
180
- # 末 response 拼到历史尾
181
- lastresp = resps.get(last["callId"])
194
+ # 末 response 拼到历史尾(仅 response 类才有 finalMessage 内容可拼)
182
195
  if lastresp and lastresp.get("kind") == "response":
183
196
  fm = lastresp.get("finalMessage") or {}
184
197
  msgs.append({"role": "assistant", "content": fm.get("content", [])})
@@ -190,8 +203,15 @@ def render_conversation(conv, resps, deref, idx):
190
203
  L.append(" " + _clip(c, CAP_TEXT))
191
204
  elif isinstance(c, list):
192
205
  for b in c:
193
- L.append(render_block(b, deref))
194
- return prompt, ts0, len(errs), len(dangling), "\n".join(L)
206
+ try:
207
+ L.append(render_block(b, deref))
208
+ except Exception as e: # 单个块渲染失败不该中断后续消息(上游 #102 加固)
209
+ L.append(f" [渲染失败 {type(e).__name__}: {str(e)[:80]}]")
210
+ # transcript 结尾显式收口——末 response 非正常时补一行,让审查者绝不把静默结束当完整
211
+ resp_ok = bool(lastresp and lastresp.get("kind") == "response")
212
+ if not resp_ok:
213
+ L.append(f"\n--- [transcript 结束] {last_state} ---")
214
+ return prompt, ts0, len(errs), len(dangling), resp_ok, "\n".join(L)
195
215
 
196
216
 
197
217
  def render_all(wire_root, out):
@@ -214,10 +234,10 @@ def render_all(wire_root, out):
214
234
  index.append(f"\n## session `{sid}` — {len(convs)} 对话 / {len(reqs)} req\n")
215
235
  for conv in convs:
216
236
  gidx += 1
217
- prompt, ts0, n_err, n_dang, md = render_conversation(conv, resps, deref, gidx)
237
+ prompt, ts0, n_err, n_dang, resp_ok, md = render_conversation(conv, resps, deref, gidx)
218
238
  fn = f"{gidx:03d}.md"
219
239
  open(os.path.join(conv_dir, fn), "w", encoding="utf-8").write(md)
220
- flag = (" ⚠️err" if n_err else "") + (" ⚠️dangling" if n_dang else "")
240
+ flag = (" ⚠️err" if n_err else "") + (" ⚠️dangling" if n_dang else "") + ("" if resp_ok else " ⚠️无末response")
221
241
  index.append(f"- [{fn}](conversations/{fn}) [{len(conv):>2} req] {prompt[:64]}{flag}")
222
242
  idxpath = os.path.join(out, "index.md")
223
243
  open(idxpath, "w", encoding="utf-8").write("\n".join(index))
@@ -278,9 +298,20 @@ def _ts_key(ts):
278
298
  return None
279
299
 
280
300
 
281
- def locate_conversation(index, started_ts, first_message):
301
+ def locate_conversation(index, started_ts, first_message, session_id=None):
282
302
  """按 (started_ts, first_message) 定位本次对话;禁用 ls -t。规则见 plan Task 3 Interfaces。
283
- 时间比较先把两侧 ISO(Z / +00:00、小数位宽不同)归一成 datetime,避免依赖字典序=数值序的脆弱假设。"""
303
+ 时间比较先把两侧 ISO(Z / +00:00、小数位宽不同)归一成 datetime,避免依赖字典序=数值序的脆弱假设。
304
+
305
+ `session_id`(2026-09-09 起,鸭嘴兽支持并发任务后):驱动侧现在知道自己跑在**哪个 gateway
306
+ session** 上(一 tab 一 session,wire 目录名就是 sessionId),传进来就先把候选缩到该 session。
307
+ 这是**确定性**定位,比 (时间, 首句) 的启发式可靠得多 —— 同一句 prompt 重跑多次时,
308
+ 启发式只能靠时间戳挑,而并发跑的多个会话时间戳本来就交叠。
309
+ 传了但该 session 在索引里一条都没有(wire 还没落盘/TTL 过期/拉的账号不对)→ **不回退**到
310
+ 全局启发式:那样会安静地定位到别的 session 的对话,比返回 None 更糟。"""
311
+ if session_id:
312
+ index = [it for it in index if it.get("sid") == session_id]
313
+ if not index:
314
+ return None
284
315
  key = (first_message or "").strip()[:40]
285
316
  cands = []
286
317
  for it in index:
@@ -85,7 +85,15 @@ def main():
85
85
 
86
86
  started_ts = _utc_now()
87
87
  answers = _parse_answers(args.answer)
88
+ # attach() 默认就自己开 tab 独占一个 gateway session("auto" 档)。两个好处——
89
+ # ① 不去抢用户已经开着的 chat tab(抢了会互相串台,见 chat_driver 模块 docstring);
90
+ # ② 拿到确定的 sessionId,后面按它**确定性**定位 wire 对话,不靠 (时间,首句) 猜。
91
+ # 该环境 multi-tab 没开(flag 默认关,cn-stage 未验)时 auto 档自己会降级复用已有 tab
92
+ # 并打 warn —— 单 driver 复用是安全的,功能不减,只是 wire 定位退回启发式。
88
93
  d = chat_driver.ChatDriver().attach()
94
+ session_id = d.session_id # attach 后立刻取:放在 try 里的话,中途抛异常会留下未绑定名
95
+ if not d.tab_isolated:
96
+ print("[warn] 未能独占 tab(该环境 multi-tab 未开)—— wire 定位退回 (时间,首句) 启发式")
89
97
  try:
90
98
  d.new_conversation()
91
99
  turns = []
@@ -99,13 +107,17 @@ def main():
99
107
  wire_root = pull_wire.pull(args.user, since_days=args.since, out=args.out)
100
108
  meta = {"env": args.env, "started_ts": started_ts, "first_message": args.message[0],
101
109
  "expect": args.expect, "issue_repo": args.issue_repo, "turns": turns,
110
+ "session_id": session_id, # 本轮跑在哪个 gateway session(= wire 目录名)
102
111
  "located": None, "located_reason": None}
103
112
  wrote_prepped = False
104
113
  if not wire_root:
105
114
  meta["located_reason"] = "no_wire_session"
106
115
  if wire_root:
107
116
  index = pull_wire.emit_conversation_index(wire_root)
108
- hit = pull_wire.locate_conversation(index, started_ts, args.message[0])
117
+ hit = pull_wire.locate_conversation(index, started_ts, args.message[0],
118
+ session_id=session_id)
119
+ if session_id and not hit:
120
+ meta["located_reason"] = f"session {session_id} 在 wire 索引里没有对话(未落盘/TTL 过期/账号不对)"
109
121
  meta["located"] = hit
110
122
  if hit:
111
123
  sdir = os.path.join(wire_root, hit["sid"])
@@ -117,7 +129,10 @@ def main():
117
129
  convs = pull_wire.segment(reqs, deref)
118
130
  conv = select_conversation_in_session(convs, deref, hit)
119
131
  if conv is not None:
120
- _, _, _, _, wire_md = pull_wire.render_conversation(conv, resps, deref, hit["gidx"])
132
+ # 6 元返回(上游 #102 起多了 resp_ok):末 response 不正常时 wire_md 结尾已显式收口,
133
+ # 但 meta 也记一份,报告层不必去 grep transcript 才知道「最终回复不可核」。
134
+ _, _, _, _, resp_ok, wire_md = pull_wire.render_conversation(conv, resps, deref, hit["gidx"])
135
+ meta["wire_last_response_ok"] = resp_ok
121
136
  prepped = prep_conversation.merge_browser_evidence(wire_md, turns)
122
137
  with open(os.path.join(args.out, "prepped.md"), "w", encoding="utf-8") as f:
123
138
  f.write(prepped)
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: "generate-test-token"
3
- description: "Use when the user needs a test merchant account, an access token for API testing, or a temporary account for CI, Stage, or Prod verification."
3
+ description: "Use when the user needs a test merchant account, an access token for API testing, or a temporary account for CI, Stage, Prod, cn-prod, or cn-stage verification."
4
4
  ---
5
5
 
6
6
  # Generate Test Access Tokens
@@ -19,6 +19,7 @@ optima-generate-test-token [options]
19
19
  optima-generate-test-token
20
20
  optima-generate-test-token --env stage
21
21
  optima-generate-test-token --business-name "Demo Shop" --env prod
22
+ optima-generate-test-token --env cn-stage
22
23
  ```
23
24
 
24
25
  ## Guidance
@@ -26,7 +27,7 @@ optima-generate-test-token --business-name "Demo Shop" --env prod
26
27
  - Default to `ci`.
27
28
  - The command handles merchant registration, OAuth token creation, and merchant profile setup.
28
29
  - The command writes the token to a temporary file; report that path back to the user.
29
- - For `prod`, remind the user that the created account will exist in the production system.
30
+ - For `prod` and `cn-prod`, remind the user that the created account will exist in the production system.
30
31
 
31
32
  ## Follow-up
32
33