From af716a3919208110ac6242b196b73939c6cb1728 Mon Sep 17 00:00:00 2001 From: John Osborne Date: Mon, 5 Oct 2026 15:49:05 -0400 Subject: [PATCH 1/3] feat(quick_ctl): status-all, chat state detection, cycling, focus; fix open-chat/new-chat selectors Add machine-readable chat-state reporting for external monitors/controllers: - status-all [--chats ...]: per-chat state (thinking/needs_input/attention/done/idle) in one CDP read - status: additive generating/needs_input/state keys for the active chat - open-chat --next/--prev: sidebar cycling with wrap-around, URL-slug active detection - focus: foreground the macOS app window via open -a (no CDP, no permission prompt) Fixes: - open-chat and open-chat --next/--prev: drop .or_() strict-mode crash; resolve the [role=option] row independently (exact -> substring -> JS fallback) - new-chat: click the primary Chat nav item (data-testid=nav-home) instead of a per-folder button CLI contract unchanged: existing commands, exit codes, and JSON keys are preserved; all changes are new keys or new commands. --- scripts/quick_ctl.py | 206 ++++++++++++++++++++++++++++++++++++++- tests/test_regression.py | 89 +++++++++++++++++ tests/test_unit.py | 136 ++++++++++++++++++++++++++ 3 files changed, 427 insertions(+), 4 deletions(-) diff --git a/scripts/quick_ctl.py b/scripts/quick_ctl.py index d94c959..8172d36 100644 --- a/scripts/quick_ctl.py +++ b/scripts/quick_ctl.py @@ -6,7 +6,9 @@ ━━━ 连接/状态 ━━━ ensure 确保 Quick 带调试端口运行(未开则带 --remote-debugging-port 重启) + focus 把 Amazon Quick 窗口前置到前台(OS 级激活,非 CDP) status JSON 打印连接/URL/模型 + status-all [--chats A B..] JSON 打印每个侧栏对话的状态(看板用;省略 --chats 则全量) screenshot [路径] 截图(默认 /tmp/quick.png) map [路径] dump 当前界面所有可交互元素(建选择器/排错用) @@ -86,6 +88,14 @@ def _out(name: str) -> str: APP = "/Applications/Amazon Quick.app/Contents/MacOS/Amazon Quick" APP_NAME = "Amazon Quick" PH = "Ask a question" +# 需要人工介入的审批/许可按钮词(英文小写,因扫描时统一转小写;中文无大小写) +NEEDS_INPUT_TOKENS = ("allow","approve","continue","grant","允许","授权","继续","同意","确认") +# 选项卡(choice card)等待态的"跳过"按钮词:真机确认 agent 等待时渲染为一组可用 radio + Skip 按钮,无 Allow/Approve +SKIP_TOKENS = ("skip","跳过") + +def _derive_state(generating, needs_input): + """纯函数:由两个布尔推导对话状态(优先级:需输入 > 生成中 > 完成)。无浏览器/无 I/O。""" + return "needs_input" if needs_input else "thinking" if generating else "done" # ---------- 连接底座 ---------- def _http_ok(): @@ -106,6 +116,17 @@ def ensure(): if _http_ok(): print(f"✓ 已带调试端口启动 (port {PORT})"); return print("✗ 无法连接 CDP",file=sys.stderr); sys.exit(1) +def focus(): + """把 Amazon Quick 窗口前置到前台(OS 级激活,非 CDP)。 + 用 open -a(无需自动化授权权限;osascript activate 需要 Automation 权限)。""" + try: + r = subprocess.run(["open", "-a", APP_NAME], capture_output=True) + except Exception as e: + print(f"✗ 无法前置 Amazon Quick: {e}", file=sys.stderr); sys.exit(1) + if r.returncode != 0: + print(f"✗ 无法前置 Amazon Quick (open 退出码 {r.returncode})", file=sys.stderr); sys.exit(1) + print("✓ 已前置 Amazon Quick") + def _page(): from playwright.sync_api import sync_playwright if not _http_ok(): @@ -141,8 +162,10 @@ def status(): pw,b,pg=_page() try: lines=[l.strip() for l in _txt(pg).split("\n") if l.strip()] + gen=_generating(pg); ni=_needs_input(pg) # 对同一个已打开的 pg 读,不另开连接 print(json.dumps({"connected":True,"port":PORT,"url":pg.url[:75], - "model":next((l for l in lines if l in("Quick","Smart","Fast","Balanced")),"?")},ensure_ascii=False)) + "model":next((l for l in lines if l in("Quick","Smart","Fast","Balanced")),"?"), + "generating":gen,"needs_input":ni,"state":_derive_state(gen,ni)},ensure_ascii=False)) finally: b.close();pw.stop() def screenshot(path=_out("quick.png")): @@ -163,6 +186,11 @@ def do_map(path=_out("quick_screen.json")): print(f"→ {path}") finally: b.close();pw.stop() +# ★ 待真机复核(BUG 2b):用户曾在生成进行中观察到 status 返回 state:done, +# 说明此构建对"进行中"控件的标注可能不同于 Stop / Stop generating。 +# 但确切信号(aria-label 文案、禁用态 composer 语义、"停止"中文变体)无法在死端口 CI 下观测, +# 盲目放宽匹配会误伤 _wait_idle/wait_done(二者都依赖 _generating),故此处【刻意】维持原判据不变, +# 待真机复核(见 .agents/tasks/verification.md 的"待真机验证"小节)后再按实测信号收窄/放宽。 def _generating(pg): """是否仍在生成:有 Stop 按钮,或页面末尾还停在 Thinking/Thought(无正文)。""" if pg.get_by_role("button",name="Stop").count()>0: return True @@ -173,6 +201,16 @@ def _generating(pg): except Exception: pass return False +def _needs_input(pg): + """是否在等待人工介入。单次 CDP 读(不另开连接),两条判据任一命中即 True: + 1) 可见的许可/审批按钮:扫 button/[role=button],(aria-label+innerText) 转小写后匹配 NEEDS_INPUT_TOKENS; + 2) 选项卡等待态(真机确认):存在可用的 button[role=radio] 且同时有一个文字含 SKIP_TOKENS 的可见按钮, + —— 这种"多选项 + Skip、无 Allow/Approve"的形态,仅靠判据1 会漏判成 done。""" + try: + return bool(pg.evaluate("([toks,skips])=>{const vis=el=>{const r=el.getBoundingClientRect();const s=getComputedStyle(el);return r.width>0&&r.height>0&&s.visibility!=='hidden'&&s.display!=='none';};const btns=[...document.querySelectorAll('button,[role=button]')].filter(vis);if(btns.some(el=>{const t=((el.getAttribute('aria-label')||'')+' '+(el.innerText||'')).toLowerCase();return toks.some(k=>t.includes(k));}))return true;const dis=el=>el.disabled||el.getAttribute('aria-disabled')==='true';const radio=[...document.querySelectorAll('button[role=radio]')].some(el=>vis(el)&&!dis(el));const skip=btns.some(el=>{const t=((el.getAttribute('aria-label')||'')+' '+(el.innerText||'')).toLowerCase();return skips.some(k=>t.includes(k));});return radio&&skip;}", [list(NEEDS_INPUT_TOKENS), list(SKIP_TOKENS)])) + except Exception: + return False + def _wait_idle(pg, before_len, timeout=240): """等生成结束:文字稳定 N 轮 + 无 Stop 按钮 + 尾部不再是 Thinking。 思考停顿会让文字暂时不变,故要求更长稳定窗口(4轮≈6s)且不在生成态。""" @@ -253,8 +291,22 @@ def stop(): finally: b.close();pw.stop() def new_chat(): + """开新对话:点左栏主导航的 "Chat"(data-testid=nav-home)。真机确认此构建无全局 "New chat" 按钮, + 按 New-chat 名称找只会命中每个文件夹里的 "New chat in folder",会误把焦点落到顶部文件夹。 + 依次尝试三个独立定位器(各自 .count() 判定,不用 .or_() 合并,避免 strict-mode 命中多元素而崩): + 1) [data-testid=nav-home] —— 命中则点 .first; + 2) 否则精确 role=button name="Chat"(exact,不子串误伤)—— 命中则点 .first; + 3) 否则 JS 兜底:点第一个 button[data-testid=nav-home],或去空白后文字恰为 "Chat" 的按钮。 + 三者皆空则中文 stderr 提示 + sys.exit(1)(不抛未捕获 traceback);走 try 内以保证 finally 关浏览器。""" pw,b,pg=_page() - try: pg.get_by_role("button",name="New chat").first.click();time.sleep(0.6);print("✓ 新对话") # nosemgrep: arbitrary-sleep -- 浏览器/应用自动化必须等渲染或 CDP 响应,非忙等 + try: + home=pg.locator("[data-testid=nav-home]") + chat=pg.get_by_role("button",name="Chat",exact=True) + if home.count()>=1: home.first.click() + elif chat.count()>=1: chat.first.click() + elif not pg.evaluate("""()=>{const b=document.querySelector('button[data-testid=nav-home]')||[...document.querySelectorAll('button')].find(x=>(x.innerText||'').trim()==='Chat');if(b){b.click();return true;}return false;}"""): + print("✗ 未找到 Chat 导航按钮",file=sys.stderr); sys.exit(1) + time.sleep(0.6); print("✓ 新对话 (Chat)") # nosemgrep: arbitrary-sleep -- 浏览器/应用自动化必须等渲染或 CDP 响应,非忙等 finally: b.close();pw.stop() def regenerate(): @@ -271,9 +323,144 @@ def list_chats(): print("\n".join(dict.fromkeys(items)) if items else "(无历史对话)") finally: b.close();pw.stop() +# JS 兜底:在 [role=option] 行里点第一个 aria-label/innerText(去空白)含 kw 的行(点行本身,不点标题 span)。 +# 镜像 _act_in/open_chat_cycle 的 JS-click 风格;命中返回 true,否则 false。 +OPEN_CHAT_FALLBACK_JS = r""" +(kw) => { + const k=(kw||'').trim(); + const opts=[...document.querySelectorAll('[role=option]')]; + const hit=opts.find(o=>(((o.getAttribute('aria-label')||'')+' '+(o.innerText||'')).trim()).includes(k)); + if(hit){hit.click();return true;} + return false; +} +""" + def open_chat(kw): - pw,b,pg=_page() - try: pg.get_by_role("option",name=kw).first.or_(pg.get_by_text(kw,exact=False).first).click();time.sleep(0.8);print(f"✓ 打开『{kw}』") # nosemgrep: arbitrary-sleep -- 浏览器/应用自动化必须等渲染或 CDP 响应,非忙等 + """按标题打开历史对话:始终点整行([role=option]),绝不点标题 span,绝不触发 strict mode。 + 依次尝试三个独立定位器(各自 .count() 判定,不用 .or_() 合并,避免 strict-mode 命中多元素而崩): + 1) 精确 role=option(exact)—— 命中则点 .first; + 2) 否则子串 role=option —— 命中则点 .first; + 3) 否则 JS 兜底:遍历 [role=option] 点第一个标题含 kw 的行。 + 三者皆空则打印中文未找到提示到 stderr 并 sys.exit(1)(不抛未捕获 traceback);stderr/exit 走 try 内以保证 finally 关浏览器。""" + pw,b,pg=_page() + try: + exact=pg.get_by_role("option",name=kw,exact=True) + contains=pg.get_by_role("option",name=kw) + if exact.count()>=1: exact.first.click() + elif contains.count()>=1: contains.first.click() + elif not pg.evaluate(OPEN_CHAT_FALLBACK_JS, kw): + print(f"✗ 未找到对话『{kw}』",file=sys.stderr); sys.exit(1) + time.sleep(0.8); print(f"✓ 打开『{kw}』") # nosemgrep: arbitrary-sleep -- 浏览器/应用自动化必须等渲染或 CDP 响应,非忙等 + finally: b.close();pw.stop() + +def _slugify(s): + """把对话标题转成 URL slug 风格:转小写、非字母数字(含中文)→连字符、去首尾连字符。 + 与 Quick 的 file:///chat// 对齐,用于反推当前激活对话。""" + out=[]; prev_dash=True # 以 True 起手可吃掉前导连字符 + for ch in s.lower(): + if ch.isalnum(): out.append(ch); prev_dash=False + elif not prev_dash: out.append("-"); prev_dash=True + return "".join(out).strip("-") + +def _active_index(names, url): + """从 URL 末段 slug 反推当前激活对话在 names 里的下标(无匹配返回 -1)。纯函数,无 I/O。 + 把每个标题 slugify 后与 URL 末段 slug 做 contains/startswith 容错匹配(slug 可能被截断)。""" + tail=(url.rstrip("/").split("/")[-1] or "").lower() # URL 末段 slug + if not tail: return -1 + for i,nm in enumerate(names): + sg=_slugify(nm) + if sg and (tail.startswith(sg) or sg.startswith(tail) or sg in tail or tail in sg): + return i + return -1 + +def open_chat_cycle(direction): + """在左侧 [role=option] 历史列表里按 direction(next/prev)循环切换,支持回绕。 + 单次 page.evaluate 返回有序名单与当前页 URL;激活项由 URL 推导(见下)。 + ★ 激活项判据改用 pg.url(真机确认):侧栏行并无 aria-selected / selected 类标记, + 唯一可靠信号是地址栏 file:///chat//。做法:把每个选项标题 slugify 后与 + URL 末段 slug 做 contains/startswith 容错匹配(slug 可能被截断),命中即激活项。 + 仅当没有任何选项匹配 URL slug 时才降级:next→第一条,prev→最后一条,并打印中文提示。""" + pw,b,pg=_page() + try: + d=pg.evaluate("""()=>{const opts=[...document.querySelectorAll('[role=option]')];const names=opts.map(e=>(e.getAttribute('aria-label')||e.innerText||'').trim());return{names:names.filter(Boolean),url:location.href};}""") + names=d["names"]; n=len(names) + if n==0: print("(无历史对话)"); return + active=_active_index(names, d["url"]) # 由 URL slug 推导激活项 + if active==-1: + print("⚠ 未能从 URL 识别当前激活对话,降级到边界项") + idx=0 if direction=="next" else n-1 + else: + idx=(active+1)%n if direction=="next" else (active-1)%n + kw=names[idx] + exact=pg.get_by_role("option",name=kw,exact=True) + contains=pg.get_by_role("option",name=kw) + if exact.count()>=1: exact.first.click() + elif contains.count()>=1: contains.first.click() + elif not pg.evaluate(OPEN_CHAT_FALLBACK_JS, kw): + print(f"✗ 未找到对话『{kw}』",file=sys.stderr); sys.exit(1) + time.sleep(0.8) # nosemgrep: arbitrary-sleep -- 浏览器/应用自动化必须等渲染或 CDP 响应,非忙等 + print(f"✓ 切换到『{kw}』") + finally: b.close();pw.stop() + +# 侧栏行扫描 JS:优先 .history-item,拿每行 [role=option] 标题 + 是否有 span.approval-dot;降级到 [role=option]。 +# 另【附加】采集每行是否报告"激活"(sel):aria-selected==='true' 或 box className 含 \bactive\b。 +# 这是加法式扩展(JSON 不删/改既有键):当前构建侧栏行据真机 ground truth 并无此标记, +# 故 sel 恒 false、不改变现有行为;若未来构建补上该标记,_dom_active_index 便可据此优先判激活项。 +STATUS_ALL_JS = r""" +() => { + const nm=o=>((o.getAttribute('aria-label')||o.innerText||'').trim()); + const act=(o,box)=>(o&&o.getAttribute('aria-selected')==='true')||/\b(active)\b/.test((box&&box.className)||''); + const rows=[]; + const items=[...document.querySelectorAll('.history-item')]; + if(items.length){ + items.forEach(it=>{const o=it.querySelector('[role=option]');const name=o?nm(o):(it.innerText||'').trim();if(name)rows.push({name,dot:!!it.querySelector('span.approval-dot'),sel:act(o,it)});}); + }else{ + [...document.querySelectorAll('[role=option]')].forEach(o=>{const name=nm(o);if(!name)return;const box=o.closest('.history-item')||o.parentElement;rows.push({name,dot:!!(box&&box.querySelector('span.approval-dot')),sel:act(o,box)});}); + } + return {rows,url:location.href}; +} +""" + +def _dom_active_index(rows): + """纯函数:若【且仅若】恰有一行 sel 为真,返回其下标;零行或多于一行报告 sel 为真则返回 -1(有歧义 → 交回 URL-slug 判据)。无 I/O。 + rows 为 [{"name","dot","sel"},...](sel 缺省按假处理)。""" + idxs=[i for i,r in enumerate(rows) if r.get("sel")] + return idxs[0] if len(idxs)==1 else -1 + +def _chat_state(dot, is_active, active_state): + """纯函数:单行最终状态。激活行用传入的 active_state;非激活行有待处理点→attention,否则 idle。""" + return active_state if is_active else ("attention" if dot else "idle") + +def _assemble_chats(rows, active_idx, active_state, chats): + """纯函数:组装输出的 chats 列表。rows 为浏览器返回的 [{"name","dot"},...]。 + chats 为 None 时按侧栏顺序全量输出(found 恒 True);否则按请求顺序精确匹配(非子串), + 缺失的名字输出 found:False、state idle、active False。无浏览器/无 I/O。""" + if chats is None: + return [{"name":r["name"],"state":_chat_state(r["dot"],i==active_idx,active_state), + "active":i==active_idx,"found":True} for i,r in enumerate(rows)] + out=[] + for req in chats: + idx=next((i for i,r in enumerate(rows) if r["name"].strip()==req.strip()),-1) # 精确匹配 + if idx==-1: out.append({"name":req,"state":"idle","active":False,"found":False}) + else: out.append({"name":rows[idx]["name"],"state":_chat_state(rows[idx]["dot"],idx==active_idx,active_state), + "active":idx==active_idx,"found":True}) + return out + +def status_all(chats=None): + """一次 CDP 连接 + 一次主 page.evaluate,报告每个侧栏对话的状态(供 Stream Deck 看板)。 + 激活对话由 URL slug 推导,其状态走 _derive_state(读已打开的 pg,不另开连接); + 其余对话按有无 span.approval-dot 映射 attention/idle。""" + pw,b,pg=_page() + try: + d=pg.evaluate(STATUS_ALL_JS) + rows=[r for r in d["rows"] if r.get("name")] # 过滤空名 + # 优先用 DOM-active 标记(恰一行报告 sel 时);无/歧义则降级到 URL-slug(既有行为,不退化)。 + active_idx=_dom_active_index(rows) + if active_idx==-1: active_idx=_active_index([r["name"] for r in rows], d["url"]) + active_state=_derive_state(_generating(pg), _needs_input(pg)) # 仅激活项读 pg,无新连接 + active_name=rows[active_idx]["name"] if active_idx>=0 else None + out=_assemble_chats(rows, active_idx, active_state, chats) + print(json.dumps({"connected":True,"active":active_name,"chats":out},ensure_ascii=False)) finally: b.close();pw.stop() def delete_chat(kw, yes=False): @@ -619,6 +806,14 @@ def wait_idle(timeout=180): try: _wait_idle(pg,0,int(timeout)); print("✓ 生成结束/空闲") finally: b.close();pw.stop() +def _chats_arg(a): + """解析 status-all 的多值 --chats:缺省返回 None(报告全部);否则取 --chats 之后、 + 到下一个以 -- 开头的 token 或列表末尾为止的所有名字。纯列表切片,死端口下不会抛异常。""" + if "--chats" not in a: return None + i=a.index("--chats")+1; names=[] + while i ast.FunctionDef: + """从源码里取出指定名字的顶层(或任意层)FunctionDef 节点(精确名匹配)。""" + tree = ast.parse(_src(name)) + for node in ast.walk(tree): + if isinstance(node, ast.FunctionDef) and node.name == fn: + return node + raise AssertionError(f"{name} 里找不到函数 {fn!r}") + + +def test_open_chat_no_or_across_locators(): + """open_chat 不得再用 `.or_(` 跨定位器合并 —— 那是触发 Playwright strict-mode 崩溃的根因。 + + 精确取 open_chat 的 FunctionDef(排除 open_chat_cycle),遍历其内所有 ast.Call, + 断言没有任何属性调用名为 or_(即 `xxx.or_(...)`)。strict-mode 下 a.or_(b) 会解析到 + 多个元素,.first 不能约束合并集 —— 故改为三个独立 .count() 判定的候选依次点击。 + """ + fn = _func_def("quick_ctl.py", "open_chat") + assert fn.name == "open_chat" # 不是 open_chat_cycle + for node in ast.walk(fn): + if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute): + assert node.func.attr != "or_", "open_chat 仍在用 .or_() 跨定位器合并(会触发 strict-mode 崩溃)" + + +def test_open_chat_targets_option_role(): + """open_chat 必须点行([role=option]),不点标题 span —— 用源码片段断言 role/option 策略被钉住。""" + fn = _func_def("quick_ctl.py", "open_chat") + seg = ast.get_source_segment(_src("quick_ctl.py"), fn) + assert seg is not None + assert "option" in seg, "open_chat 不再定位 role=option 行" + assert "role" in seg, "open_chat 不再按 role 定位" + + +def test_open_chat_cycle_no_or_across_locators(): + """open_chat_cycle 不得再用 `.or_(` 跨定位器合并 —— 与 open_chat 同根因的 strict-mode 崩溃。 + + 精确取 open_chat_cycle 的 FunctionDef,遍历其内所有 ast.Call,断言没有任何 + 属性调用名为 or_(即 `xxx.or_(...)`)。切换对话的点击改为三个独立 .count() 判定的候选依次点行。 + """ + fn = _func_def("quick_ctl.py", "open_chat_cycle") + assert fn.name == "open_chat_cycle" + for node in ast.walk(fn): + if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute): + assert node.func.attr != "or_", "open_chat_cycle 仍在用 .or_() 跨定位器合并(会触发 strict-mode 崩溃)" + + +def test_open_chat_cycle_targets_option_role(): + """open_chat_cycle 必须点行([role=option]),不点标题 span —— 源码片段断言 role/option 策略被钉住。""" + fn = _func_def("quick_ctl.py", "open_chat_cycle") + seg = ast.get_source_segment(_src("quick_ctl.py"), fn) + assert seg is not None + assert "option" in seg, "open_chat_cycle 不再定位 role=option 行" + assert "role" in seg, "open_chat_cycle 不再按 role 定位" + + +def test_new_chat_targets_nav_home(): + """new_chat 必须改点主导航 "Chat"(data-testid=nav-home),不得再用裸 name="New chat" 选择器。 + + 真机确认此构建无全局 "New chat" 按钮,name="New chat" 只命中每文件夹的 "New chat in folder", + 会误把焦点落到顶部文件夹。精确取 new_chat 的源码片段断言: + (a) 新选择器字面量 nav-home 已就位; + (b) 旧的 name="New chat" 选择器已消失。 + """ + fn = _func_def("quick_ctl.py", "new_chat") + seg = ast.get_source_segment(_src("quick_ctl.py"), fn) + assert seg is not None + assert "nav-home" in seg, "new_chat 未改用 data-testid=nav-home 选择器" + assert 'name="New chat"' not in seg, "new_chat 仍在用裸 name=\"New chat\" 选择器(会误点每文件夹的 New chat in folder)" + + +@pytest.mark.parametrize("flag", ["--next", "--prev"]) +def test_quick_ctl_open_chat_cycle_flags_present(flag): + """open-chat 的循环切换标志必须作为 Compare 字面量出现在 dispatch 里。 + + 守护 open-chat --next/--prev 路由不被误删 —— Stream Deck 轮换按钮靠它。 + """ + assert flag in _dispatch_literals("quick_ctl.py"), \ + f"open-chat 循环标志 {flag!r} 从 quick_ctl.py 的 dispatch 中消失" + + def test_quick_web_py_absent(): """quick_web.py 不应存在。 diff --git a/tests/test_unit.py b/tests/test_unit.py index 0c945c1..07451b7 100644 --- a/tests/test_unit.py +++ b/tests/test_unit.py @@ -8,9 +8,145 @@ sys.path.insert(0, SCRIPTS) import chrome_as # noqa: E402 +import quick_ctl # noqa: E402 import web_ensure # noqa: E402 +# ---------- quick_ctl:状态推导纯函数 + 需输入词表 ---------- + +def test_derive_state_precedence(): + """优先级:需输入 > 生成中 > 完成。needs_input 永远压过 generating。""" + assert quick_ctl._derive_state(True, True) == "needs_input" + assert quick_ctl._derive_state(True, False) == "thinking" + assert quick_ctl._derive_state(False, True) == "needs_input" + assert quick_ctl._derive_state(False, False) == "done" + + +def test_needs_input_tokens(): + """英文审批词必须以小写存储(扫描时统一转小写才能命中),中文词原样在列表里。""" + for t in ("allow", "approve", "continue", "grant"): + assert t in quick_ctl.NEEDS_INPUT_TOKENS + assert t == t.lower() + for t in ("允许", "授权", "继续", "同意", "确认"): + assert t in quick_ctl.NEEDS_INPUT_TOKENS + + +def test_choice_card_skip_tokens(): + """选项卡等待态词表:英文 skip 小写存储,中文"跳过"原样;_needs_input 第二条判据靠它。 + + 真机确认:agent 等待时渲染为一组可用 button[role=radio] + Skip 按钮(无 Allow/Approve), + 仅靠审批词会漏判成 done,故需这组 Skip 词支撑选项卡判据。 + """ + assert "skip" in quick_ctl.SKIP_TOKENS and "skip" == "skip".lower() + assert "跳过" in quick_ctl.SKIP_TOKENS + + +def test_derive_state_needs_input_from_choice_card(): + """选项卡等待态下 _needs_input 为真(generating 为假)时,状态仍应是 needs_input。""" + assert quick_ctl._derive_state(False, True) == "needs_input" + + +# ---------- quick_ctl:status-all 纯逻辑(无浏览器) ---------- + +def test_chat_state_truth_table(): + """单行状态真值表:非激活行 dot→attention 否则 idle;激活行原样返回 active_state。""" + assert quick_ctl._chat_state(True, False, "done") == "attention" + assert quick_ctl._chat_state(False, False, "done") == "idle" + # 激活行:不论 dot,返回传入的 active_state + for st in ("thinking", "needs_input", "done"): + assert quick_ctl._chat_state(True, True, st) == st + assert quick_ctl._chat_state(False, True, st) == st + + +def test_active_index(): + """_active_index:精确 slug 命中 / 截断 slug 的 startswith 容错 / 无匹配 → -1。""" + names = ["Checking email reply status", "AI integration strategy at T. Rowe Price"] + # 精确:URL 末段恰为完整 slug + assert quick_ctl._active_index(names, "file:///chat/d01da364/checking-email-reply-status") == 0 + # 截断容错:URL slug 是标题 slug 的前缀 + assert quick_ctl._active_index(names, "file:///chat/x/ai-integration-strategy") == 1 + # 无匹配 + assert quick_ctl._active_index(names, "file:///chat/x/something-else-entirely") == -1 + # 空 URL 末段 + assert quick_ctl._active_index(names, "") == -1 + + +def test_assemble_chats_all(): + """chats=None:按侧栏顺序全量输出,全部 found=True,仅 active_idx 行 active=True 且状态正确。""" + rows = [{"name": "A", "dot": False}, {"name": "B", "dot": True}, {"name": "C", "dot": False}] + out = quick_ctl._assemble_chats(rows, active_idx=0, active_state="thinking", chats=None) + assert [c["name"] for c in out] == ["A", "B", "C"] + assert all(c["found"] for c in out) + assert [c["active"] for c in out] == [True, False, False] + assert [c["state"] for c in out] == ["thinking", "attention", "idle"] + + +def test_assemble_chats_selection_order_and_missing(): + """chats 给定:保序、精确匹配(非子串)、缺失名字 found=False/state idle/active False。""" + rows = [{"name": "Chat A", "dot": False}, + {"name": "Chat AA", "dot": True}, + {"name": "Chat B", "dot": True}] + out = quick_ctl._assemble_chats(rows, active_idx=0, active_state="done", + chats=["Chat B", "Nope", "Chat A"]) + assert [c["name"] for c in out] == ["Chat B", "Nope", "Chat A"] + # 顺序保持;缺失项 + assert out[1] == {"name": "Nope", "state": "idle", "active": False, "found": False} + # "Chat A" 精确匹配到下标 0(active),不会误配 "Chat AA" + assert out[2]["found"] is True and out[2]["active"] is True and out[2]["state"] == "done" + # "Chat B" 有 dot 但非激活 → attention + assert out[0]["found"] is True and out[0]["active"] is False and out[0]["state"] == "attention" + + +def test_dom_active_index(): + """_dom_active_index:恰一行 sel=True → 该下标;零行 sel → -1;多于一行 sel=True → -1(歧义交回 URL-slug)。""" + rows = [{"name": "A", "dot": False, "sel": False}, + {"name": "B", "dot": False, "sel": True}, + {"name": "C", "dot": False, "sel": False}] + assert quick_ctl._dom_active_index(rows) == 1 + # 零行报告 sel → -1 + none_sel = [{"name": "A", "dot": False, "sel": False}, + {"name": "B", "dot": True, "sel": False}] + assert quick_ctl._dom_active_index(none_sel) == -1 + # 两行 sel=True → 歧义 → -1 + two_sel = [{"name": "A", "dot": False, "sel": True}, + {"name": "B", "dot": False, "sel": True}] + assert quick_ctl._dom_active_index(two_sel) == -1 + # sel 键缺省按假处理 + assert quick_ctl._dom_active_index([{"name": "A", "dot": False}]) == -1 + + +def test_chats_arg_parsing(): + """--chats 多值解析:缺省 None;收集到下一个 -- 开头 token 或末尾为止。""" + assert quick_ctl._chats_arg([]) is None + assert quick_ctl._chats_arg(["--other"]) is None + assert quick_ctl._chats_arg(["--chats", "A", "B", "C"]) == ["A", "B", "C"] + assert quick_ctl._chats_arg(["--chats", "A", "--flag", "B"]) == ["A"] + assert quick_ctl._chats_arg(["--chats"]) == [] + + +# ---------- quick_ctl:focus(OS 级前置,全程 mock subprocess.run,绝不 spawn) ---------- + +def test_focus_invokes_open_a(monkeypatch): + """focus() 必须用 open -a 'Amazon Quick' 前置,且不真正 spawn。""" + calls = [] + class _R: returncode = 0 + def fake_run(argv, *a, **k): + calls.append(argv); return _R() + monkeypatch.setattr(quick_ctl.subprocess, "run", fake_run) + quick_ctl.focus() + assert calls == [["open", "-a", "Amazon Quick"]] + + +def test_focus_nonzero_exits(monkeypatch): + """open 返回非零时 focus() 必须 sys.exit(1)。""" + import pytest + class _R: returncode = 1 + monkeypatch.setattr(quick_ctl.subprocess, "run", lambda *a, **k: _R()) + with pytest.raises(SystemExit) as ei: + quick_ctl.focus() + assert ei.value.code == 1 + + # ---------- chrome_as:_as_string 是 v1.x AppleScript 遗留纯函数 ---------- # CDP 实现不再调用它,保留是为了这几项断言(以及记录当年的转义规则)。 From a7030835cb0a9551de57a7008a25eb1468c0b196 Mon Sep 17 00:00:00 2001 From: John Osborne Date: Mon, 5 Oct 2026 15:49:05 -0400 Subject: [PATCH 2/3] docs(changelog): add 2.1.0 release notes and bump version Bump pyproject version to 2.1.0 to match the new top CHANGELOG entry (CI enforces this). --- CHANGELOG.md | 39 +++++++++++++++++++++++++++++++++++++++ pyproject.toml | 2 +- 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e806481..124a822 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,45 @@ ### Fixed - `docs/architecture.svg` 里 `quick_ctl.py · 27+ 能力` 是 v1.x 遗留计数,实际 dispatch 表为 **59** 个命令(README 正文一直写的 59 才是对的)。中英两版图均已改为 59。 +## [2.1.0] - 2026-10-05 + +Desktop channel (`quick_ctl.py`) gains machine-readable chat-state reporting and +chat navigation, intended for external monitors/controllers (e.g. a Stream Deck +plugin) that poll the CLI and branch on its JSON. The CLI contract stays +backward compatible: existing commands, exit codes, and JSON keys are unchanged; +all additions are new keys or new commands. (Note: per the repo convention, the +code comments remain in Simplified Chinese; this entry is in English for the +public changelog.) + +### Added +- **`status-all [--chats A B ...]`** — one CDP connection reports every sidebar + chat's state as JSON `{connected, active, chats:[{name, state, active, found}]}`. + Non-active chats are `attention` (sidebar `span.approval-dot` present, i.e. + awaiting input) or `idle`; the active chat (identified from the page URL slug) + gets a full `thinking | needs_input | done`. Names are matched exactly and the + requested order is preserved; a name not found returns `found:false`. +- **`status` gains three additive keys** — `generating` (bool), `needs_input` + (bool), and `state` (`thinking | needs_input | done`) for the active chat. + `needs_input` detects both an approval/permission button and the + choice-card-waiting pattern (an enabled `role=radio` group plus a Skip button). + Existing `status` keys (`connected`, `port`, `url`, `model`) are unchanged. +- **`open-chat --next` / `open-chat --prev`** — cycle through the sidebar chat + list with wrap-around; the active chat is derived from the page URL. +- **`focus`** — bring the Amazon Quick macOS window to the foreground via + `open -a` (no CDP connection, no Automation permission prompt). Useful for a + remote controller to pull the app back after switching away. + +### Fixed +- **`open-chat` strict-mode crash** — the sidebar locator combined two matchers + with `.or_()`, which Playwright rejects in strict mode when the union resolves + to more than one element (the chat row and its title span). It now resolves the + `[role=option]` row independently (exact name, then substring, then a JS + fallback) and never raises; a missing chat exits non-zero instead of a + traceback. The same fix is applied to `open-chat --next/--prev`. +- **`new-chat` targeted the wrong control** — it matched a per-folder "New chat" + button and focused the first folder. It now clicks the primary "Chat" nav item + (`data-testid=nav-home`), landing on a fresh chat. + ## [2.0.0] - 2026-07-27 **架构收敛:双目标 · 单协议。** 网页版通道底层从 AppleScript 换成 CDP,两条通道(桌面端 Electron `:9333` / 网页版 Chrome `:9445`)从此说同一种协议。触发这次迁移的不是新需求,而是**发现 v1.x 把网页通道锁在 AppleScript 上的那条技术结论是误判**(见 Fixed)。 diff --git a/pyproject.toml b/pyproject.toml index 4d9e94a..e180cc3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sample-quick-control" -version = "2.0.0" +version = "2.1.0" description = "程序化控制本机 macOS 上的 Amazon Quick Desktop 应用与网页版 —— 双目标·单协议:桌面端 Electron CDP (:9333) + 网页版 Chrome CDP (:9445)" readme = "README.md" requires-python = ">=3.10" From 182a43c5f24060135aaf111923593c4c2cefa20e Mon Sep 17 00:00:00 2001 From: John Osborne Date: Mon, 5 Oct 2026 15:49:05 -0400 Subject: [PATCH 3/3] chore(gitignore): ignore local AI-agent scaffolding dirs Ignore .agents/, .kiro/, and .worktrees/ so local agent tooling never gets committed. --- .gitignore | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitignore b/.gitignore index 03741d7..e7ce75d 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,7 @@ uv.lock # playwright node_modules/ +# AI agent tooling / local scaffolding (not part of the project) +.agents/ +.kiro/ +.worktrees/