Download app/tests/test_app_sem_arvore.py from OpenTARS/openTARS: direct link, hf CLI and curl.
- Browser
- Download file 10.7 kB
-
https://huggingface.co/OpenTARS/openTARS/resolve/main/app/tests/test_app_sem_arvore.py
- Command line
-
hf download hf://OpenTARS/openTARS/app/tests/test_app_sem_arvore.py
-
curl -L -o test_app_sem_arvore.py https://huggingface.co/OpenTARS/openTARS/resolve/main/app/tests/test_app_sem_arvore.py
10.7 kB
| """2.6.1: app aberto que não expõe os botões (Electron: Claude, Discord...). | |
| Log real: "open claude and type..." -> a janela do Claude não tem árvore de | |
| acessibilidade; a IA tentou click_element com 14 nomes diferentes (tirados | |
| da descrição do print), todos falhando, até o limite de etapas. | |
| O xmessage (X puro, sem acessibilidade) faz o papel do app Electron.""" | |
| import os | |
| import shutil | |
| import subprocess | |
| import sys | |
| import time | |
| from _util import carregar_tars | |
| tars = carregar_tars() | |
| # ------------------------------------------------------------ | |
| # 1. Parte com a janela de verdade (precisa de X + D-Bus + xmessage) | |
| # ------------------------------------------------------------ | |
| tem_x = (os.environ.get("DISPLAY") and os.environ.get("DBUS_SESSION_BUS_ADDRESS") | |
| and shutil.which("xmessage") and not tars.acess.indisponivel()) | |
| if tem_x: | |
| proc = subprocess.Popen(["xmessage", "-name", "clauded", "-title", "Claude", "Olá, eu sou um app Electron"], | |
| stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) | |
| try: | |
| limite = time.time() + 10 | |
| while time.time() < limite and not tars.encontrar_janela_por_nome("Claude"): | |
| time.sleep(0.2) | |
| abriu = [] | |
| tars.abrir_aplicativo = lambda app: abriu.append(app) or {"sucesso": True} | |
| r = tars.executar_ferramenta("click_element", {"name": "New chat", "window": "Claude"}) | |
| assert not r["sucesso"] and "does not expose" in r["mensagem"] and "type_in_element" in r["mensagem"], r | |
| assert not abriu, "a janela já estava aberta: não abre o app de novo" | |
| r2 = tars.executar_ferramenta("list_elements", {"window": "Claude"}) | |
| if getattr(tars, "ocr", None) and tars.ocr.disponivel(): # o OCR saiu do app; o ramo fica para se voltar | |
| # 2.9: sem árvore, a janela é lida pela tela (OCR) | |
| assert r2["sucesso"] and "READ the window" in r2["mensagem"] and r2["textos"], r2 | |
| else: | |
| assert not r2["sucesso"] and "Do NOT use click_element" in r2["mensagem"] | |
| print("OK: janela aberta sem botões expostos -> diz na hora pra usar type_in_element/click_mouse (e não reabre o app)") | |
| # type_in_element numa janela dessas: foca a janela, digita no campo com foco e aperta Enter | |
| digitado, teclas = [], [] | |
| tars.digitar_texto = lambda txt: digitado.append(txt) or {"sucesso": True} | |
| tars.pressionar_tecla = lambda k: teclas.append(k) or {"sucesso": True} | |
| r3 = tars.executar_ferramenta("type_in_element", {"text": "Qual a capital da França?", "window": "Claude", | |
| "submit": "true"}) | |
| assert r3["sucesso"] and digitado == ["Qual a capital da França?"] and teclas == ["enter"], (r3, digitado, teclas) | |
| ativa = tars.janela_ativa_x() | |
| # sem gerenciador de janelas (o xvfb da suíte) nenhuma janela fica "ativa": só confere quando há um | |
| tem_gerenciador = subprocess.run(["xprop", "-root", "_NET_SUPPORTING_WM_CHECK"], capture_output=True, | |
| text=True).stdout.count("window id") > 0 if shutil.which("xprop") else False | |
| if tem_gerenciador: | |
| assert ativa == tars.encontrar_janela_por_nome("Claude")["id"], "a janela do app veio pra frente antes de digitar" | |
| print("OK: type_in_element(window, submit=true) num app sem botões: foca a janela, digita e envia") | |
| finally: | |
| proc.kill() | |
| else: | |
| print("PULADO (parte 1): precisa de DISPLAY, D-Bus, xmessage e acessibilidade") | |
| # ------------------------------------------------------------ | |
| # 2. O loop do log: a IA insiste no click_element com nomes diferentes | |
| # ------------------------------------------------------------ | |
| tars.carregar_modelo = lambda m, silencioso=False: True | |
| tars.contexto_ia = lambda: 8192 | |
| tars.modo_modelo = "MANUAL" | |
| tars.catalogar_modelos = lambda forcar=False: {} | |
| nomes = iter(f"Botão {i}" for i in range(100)) | |
| turnos = [] | |
| def turno(modelo, msgs, tools, **kw): | |
| turnos.append(bool(tools)) | |
| if not tools: | |
| return {"content": "Não consegui clicar: o app não mostra os botões. Clique você no campo.", "tool_calls": []}, None | |
| return {"content": "", "tool_calls": [{"function": {"name": "click_element", | |
| "arguments": {"name": next(nomes), "window": "Claude"}}}]}, None | |
| tars._executar_turno_streaming = turno | |
| tars._DISPATCH_FERRAMENTAS["click_element"] = lambda a: {"sucesso": False, "mensagem": f"No element named '{a['name']}'."} | |
| tars.conversa = [] | |
| resposta = tars.perguntar_modelo("qwen3:8b", "open claude and type hello", tarefa="acao") | |
| assert "Não consegui" in resposta, resposta | |
| assert turnos.count(True) == tars.LIMITE_FALHAS_SEGUIDAS and turnos[-1] is False, turnos | |
| resultados = [m["content"] for m in tars.obter_sessao("qwen3:8b") if m.get("role") == "tool"] | |
| assert "STOP" not in resultados[1] and "STOP: click_element failed 3 times" in resultados[2], resultados[:3] | |
| print(f"OK: 3 falhas seguidas -> 'mude de estratégia'; {tars.LIMITE_FALHAS_SEGUIDAS} -> para e explica ao usuário " | |
| f"(antes: 20 etapas)") | |
| # 3. A mesma chamada que já falhou nem roda de novo | |
| rodou = [] | |
| tars._DISPATCH_FERRAMENTAS["click_element"] = lambda a: rodou.append(a) or {"sucesso": False, "mensagem": "nope."} | |
| chamadas = iter([("click_element", {"name": "Send", "window": "Claude"})] * 2 + [None]) | |
| def turno2(modelo, msgs, tools, **kw): | |
| proxima = next(chamadas) | |
| if proxima is None or not tools: | |
| return {"content": "ok", "tool_calls": []}, None | |
| return {"content": "", "tool_calls": [{"function": {"name": proxima[0], "arguments": proxima[1]}}]}, None | |
| tars._executar_turno_streaming = turno2 | |
| tars.perguntar_modelo("qwen3:8b", "type hello in claude", tarefa="acao") | |
| ultimo = [m["content"] for m in tars.obter_sessao("qwen3:8b") if m.get("role") == "tool"][-1] | |
| assert len(rodou) == 1 and "already tried exactly this" in ultimo, (rodou, ultimo) | |
| print("OK: repetir exatamente a mesma chamada que falhou não roda de novo (a IA é avisada)") | |
| # 4. Descrição da tela de um app sem botões expostos: manda clicar por coordenada | |
| tars._descrever_print = lambda img, pedido, modelo: ("Message box at (400, 700). Send button at (760, 700).", "gemma4:e4b") | |
| tars._marcar_sem_arvore("Claude") | |
| msg = tars._print_em_texto("x", "type hello", "qwen3:8b", "Claude") | |
| assert "click_mouse(x, y, target=" in msg and "click_element(name)" not in msg, msg | |
| msg_outro = tars._print_em_texto("x", "type hello", "qwen3:8b", "calculator") | |
| assert "click_element(name)" in msg_outro | |
| print("OK: com um app sem botões expostos, a descrição da tela manda usar click_mouse(x, y, target=...) e type_text") | |
| # 5. Abrir app pede a árvore de acessibilidade ao Chromium/Electron | |
| envs = [] | |
| class Popen: | |
| def __init__(self, *a, **kw): | |
| envs.append(kw.get("env") or {}) | |
| self.returncode = 0 | |
| def poll(self): | |
| return 0 | |
| fonte = carregar_tars() # abrir_aplicativo original (a parte 1 trocou o do outro) | |
| fonte.subprocess.Popen = Popen | |
| fonte.encontrar_app = lambda app: {"comando": ["claude-desktop"], "nome": "Claude", "tipo": "desktop"} | |
| fonte.listar_janelas_x = lambda: None | |
| fonte._processo_ativo = lambda termos: True | |
| fonte.abrir_aplicativo("claude") | |
| assert envs and envs[-1].get("ACCESSIBILITY_ENABLED") == "1", envs | |
| print("OK: apps são abertos com ACCESSIBILITY_ENABLED=1 (Electron/Chromium passam a expor os botões)") | |
| # 6. Log real da 2.6.1: uma falha e a IA manda o usuário fazer na mão | |
| chamadas_ia = iter([ | |
| ("tool", "click_element", {"name": "Ask a question", "window": "Claude"}), | |
| ("texto", "I cannot directly interact with the Claude app's buttons. To ask a question, focus on the text box and type your question, then press Enter."), | |
| ("tool", "type_in_element", {"text": "What is the capital of France?", "window": "Claude", "submit": True}), | |
| ("texto", "Done: I asked Claude 'What is the capital of France?'."), | |
| ]) | |
| recebeu = [] | |
| def turno3(modelo, msgs, tools, **kw): | |
| recebeu.append(msgs[-1]["content"]) | |
| tipo, *resto = next(chamadas_ia) | |
| if tipo == "texto": | |
| return {"content": resto[0], "tool_calls": []}, None | |
| return {"content": "", "tool_calls": [{"function": {"name": resto[0], "arguments": resto[1]}}]}, None | |
| tars._executar_turno_streaming = turno3 | |
| tars._DISPATCH_FERRAMENTAS["click_element"] = lambda a: {"sucesso": False, "mensagem": tars._mensagem_sem_arvore("Claude")} | |
| escrito = [] | |
| tars._DISPATCH_FERRAMENTAS["type_in_element"] = lambda a: escrito.append(a) or {"sucesso": True, "mensagem": "SUCCESS: typed and sent."} | |
| resposta = tars.perguntar_modelo("qwen3:8b", "abra o claude e faça uma pergunta simples", tarefa="acao") | |
| assert resposta.startswith("Done") and escrito and escrito[0]["submit"] is True, (resposta, escrito) | |
| assert any("Don't hand the task back" in r for r in recebeu) | |
| assert not any("I cannot directly interact" in (m.get("content") or "") for m in tars.obter_sessao("qwen3:8b")) | |
| print("OK: 'foque a caixa e digite você' depois de uma falha -> a IA é mandada fazer ela mesma, e faz") | |
| # 7. Electron/Chromium detectado -> abre com --force-renderer-accessibility | |
| import tempfile # noqa: E402 | |
| pasta = tempfile.mkdtemp(prefix="opentars-electron-") | |
| for arquivo, conteudo in (("claude-desktop", b"\x7fELF..."), ("resources.pak", b"")): | |
| with open(os.path.join(pasta, arquivo), "wb") as f: | |
| f.write(conteudo) | |
| script = os.path.join(pasta, "discord") | |
| with open(script, "wb") as f: | |
| f.write(b"#!/bin/sh\nexec /usr/lib/electron/electron /usr/lib/discord/app.asar \"$@\"\n") | |
| comum = os.path.join(tempfile.mkdtemp(), "gnome-calculator") | |
| with open(comum, "wb") as f: | |
| f.write(b"\x7fELF-normal") | |
| assert fonte._eh_electron([os.path.join(pasta, "claude-desktop")]), "resources.pak ao lado: Electron" | |
| assert fonte._eh_electron([script]), "script lançador que chama o electron" | |
| assert not fonte._eh_electron([comum]) and not fonte._eh_electron(["flatpak", "run", "x"]) | |
| envs.clear() | |
| chamados = [] | |
| class PopenComando(Popen): | |
| def __init__(self, comando, *a, **kw): | |
| chamados.append(list(comando)) | |
| super().__init__(comando, *a, **kw) | |
| fonte.subprocess.Popen = PopenComando | |
| fonte.encontrar_app = lambda app: {"comando": [os.path.join(pasta, "claude-desktop")], "nome": "Claude", "tipo": "desktop"} | |
| fonte.abrir_aplicativo("claude") | |
| fonte.encontrar_app = lambda app: {"comando": [comum], "nome": "Calculadora", "tipo": "desktop"} | |
| fonte.abrir_aplicativo("calculadora") | |
| assert chamados[0][-1] == "--force-renderer-accessibility" and chamados[1] == [comum], chamados | |
| print("OK: app Electron é aberto com --force-renderer-accessibility (os outros, do jeito normal)") | |
| print("\nTUDO OK.") | |