@optima-chat/dev-skills 0.16.7 → 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.
- package/.claude/commands/logs.md +1 -1
- package/.claude/commands/query-db.md +6 -1
- package/.claude/skills/discount-codes/SKILL.md +4 -4
- package/.claude/skills/generate-test-token/SKILL.md +8 -4
- package/.claude/skills/logs/SKILL.md +5 -3
- package/.claude/skills/show-env/SKILL.md +1 -1
- package/.claude/skills/yzsgo-e2e/SKILL.md +19 -0
- package/.claude/skills/yzsgo-e2e/SYNC.md +26 -2
- package/.claude/skills/yzsgo-e2e/chat_driver.py +221 -47
- package/.claude/skills/yzsgo-e2e/pull_wire.py +39 -8
- package/.claude/skills/yzsgo-e2e/run_e2e.py +17 -2
- package/.codex/skills/generate-test-token/SKILL.md +3 -2
- package/.codex/skills/logs/SKILL.md +4 -2
- package/.codex/skills/show-env/SKILL.md +4 -2
- package/.codex/skills/yzsgo-e2e/SKILL.md +19 -0
- package/.codex/skills/yzsgo-e2e/SYNC.md +26 -2
- package/.codex/skills/yzsgo-e2e/chat_driver.py +221 -47
- package/.codex/skills/yzsgo-e2e/pull_wire.py +39 -8
- package/.codex/skills/yzsgo-e2e/run_e2e.py +17 -2
- package/AGENTS.md +1 -1
- package/bin/cli.js +3 -3
- package/bin/helpers/billing-http.ts +12 -3
- package/bin/helpers/db-utils.ts +8 -5
- package/bin/helpers/generate-test-token.ts +40 -3
- package/bin/helpers/verify-health.ts +77 -29
- package/dist/bin/helpers/billing-http.js +9 -1
- package/dist/bin/helpers/db-utils.js +8 -2
- package/dist/bin/helpers/generate-test-token.js +40 -3
- package/dist/bin/helpers/verify-health.js +83 -27
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
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
|
-
|
|
58
|
-
self.browser.close()
|
|
182
|
+
self._close_own_tab()
|
|
59
183
|
finally:
|
|
60
|
-
|
|
61
|
-
self.
|
|
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
|
-
# ──
|
|
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
|
-
"""
|
|
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
|
-
|
|
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
|
-
⚠️
|
|
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
|
-
#
|
|
313
|
-
|
|
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
|
|
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
|
-
#
|
|
356
|
-
# ①
|
|
357
|
-
#
|
|
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
|
|
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
|
-
|
|
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)
|
|
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 &&
|
|
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(
|
|
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 &&
|
|
531
|
-
const clean=ops.filter(op=>((op.parentElement?.innerText||'').match(
|
|
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()
|
|
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
|
|
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
|
|
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
|
-
|
|
194
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|